gangtise-openapi-cli 0.43.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +93 -19
  2. package/dist/src/cli.js +29 -18
  3. package/dist/src/commands/ai.js +20 -29
  4. package/dist/src/commands/auth.js +5 -4
  5. package/dist/src/commands/bond.js +5 -4
  6. package/dist/src/commands/fund.js +100 -0
  7. package/dist/src/commands/fundamental.js +2 -0
  8. package/dist/src/commands/groups.js +14 -0
  9. package/dist/src/commands/indicator.js +5 -5
  10. package/dist/src/commands/insight.js +3 -4
  11. package/dist/src/commands/raw.js +4 -3
  12. package/dist/src/commands/shared.js +35 -6
  13. package/dist/src/commands/tool.js +2 -2
  14. package/dist/src/commands/vault.js +30 -30
  15. package/dist/src/core/args.js +23 -7
  16. package/dist/src/core/asyncContent.js +1 -1
  17. package/dist/src/core/auth.js +2 -10
  18. package/dist/src/core/client.js +96 -35
  19. package/dist/src/core/commandBodies.js +1 -27
  20. package/dist/src/core/config.js +9 -5
  21. package/dist/src/core/download.js +47 -10
  22. package/dist/src/core/endpoints.js +198 -3
  23. package/dist/src/core/errors.js +1 -1
  24. package/dist/src/core/opinionDetail.js +16 -6
  25. package/dist/src/core/output.js +103 -72
  26. package/dist/src/core/printer.js +10 -5
  27. package/dist/src/core/quoteSharding.js +28 -4
  28. package/dist/src/core/rowSink.js +24 -10
  29. package/dist/src/core/titleCache.js +2 -10
  30. package/dist/src/core/transport.js +25 -7
  31. package/dist/src/core/updateCheck.js +3 -9
  32. package/gangtise-openapi/SKILL.md +53 -17
  33. package/gangtise-openapi/references/commands/fund.md +192 -0
  34. package/gangtise-openapi/references/commands/fundamental.md +2 -2
  35. package/gangtise-openapi/references/commands/indicator.md +7 -7
  36. package/gangtise-openapi/references/commands/insight.md +3 -3
  37. package/gangtise-openapi/references/commands/quote.md +1 -1
  38. package/gangtise-openapi/references/commands/tool.md +1 -1
  39. package/gangtise-openapi/references/commands/vault.md +1 -1
  40. package/gangtise-openapi/references/errors.md +5 -5
  41. package/gangtise-openapi/references/response-schema.md +10 -0
  42. package/package.json +1 -1
@@ -0,0 +1,100 @@
1
+ import { Command } from "commander";
2
+ import { collectNames } from "../core/args.js";
3
+ import { date, format, list, output, query, requiredList, value } from "./shared.js";
4
+ export const fund = new Command("fund").description("Fund APIs: profiles, NAV, fees, managers, scale, holders, holdings and allocation, ETF creation/redemption");
5
+ const code = () => requiredList("--security <code>", "Fund code, e.g. 005827.OF / 159967.SZ / 510300.SH (repeatable, comma-separated accepted)", "fundCodeList");
6
+ const range = (what) => [
7
+ date("--start-date <date>", `${what} range start (yyyy-MM-dd); omit both ends for everything in your account's history window`, "startDate"),
8
+ date("--end-date <date>", `${what} range end (yyyy-MM-dd)`, "endDate"),
9
+ ];
10
+ const positionType = () => value("--position-type <type>", "top = top holdings, disclosed every quarter (server default); all = full book, interim and annual reports only", "positionType");
11
+ query(fund, "basic-info", {
12
+ description: "Fund profiles: category, manager, custodian, dates, subscription rules, benchmark, risk level, tracked index",
13
+ endpoint: "fund.basic-info",
14
+ fields: [code(), list("--field <field>", "Field to return (repeatable); omit for all. An unsupported name rejects the whole call with 100003", "fieldList"), format(), output()],
15
+ });
16
+ query(fund, "nav", {
17
+ description: "Daily NAV: unit, accumulated and adjusted NAV, adjustment factor; money-market funds add 7-day annualized yield and income per 10k units",
18
+ endpoint: "fund.nav",
19
+ fields: [code(), ...range("Trade date"), format(), output()],
20
+ });
21
+ query(fund, "fee-rate", {
22
+ description: "Current fee rates by type, one row per condition band (holding period, amount)",
23
+ endpoint: "fund.fee-rate",
24
+ fields: [code(), list("--fee-type <type>", "purchaseFee / redemptionFee / managementFee / custodianFee / saleFee (repeatable); omit for all", "feeTypeList"), format(), output()],
25
+ });
26
+ query(fund, "manager-info", {
27
+ description: "Fund manager profiles by name: career, funds and assets under management, introduction",
28
+ endpoint: "fund.manager-info",
29
+ fields: [requiredList("--manager <name>", "Manager name, e.g. 张坤 (repeatable). Exact match; every manager with that name comes back — tell them apart by currentCompany", "managerNameList", collectNames), format(), output()],
30
+ });
31
+ query(fund, "manager-history", {
32
+ description: "Past and present managers of a fund: tenure, days managed, return over tenure (endDate null = current)",
33
+ endpoint: "fund.manager-history",
34
+ fields: [code(), format(), output()],
35
+ });
36
+ query(fund, "asset-size", {
37
+ description: "Shares at start and end of each report period, subscriptions, redemptions, net assets",
38
+ endpoint: "fund.asset-size",
39
+ fields: [code(), ...range("Report date"), format(), output()],
40
+ });
41
+ query(fund, "holder-structure", {
42
+ description: "Holder count and institutional / individual / employee holdings per report date",
43
+ endpoint: "fund.holder-structure",
44
+ fields: [code(), ...range("Report date"), format(), output()],
45
+ });
46
+ query(fund, "top10-holders", {
47
+ description: "Top 10 holders of a listed fund (LOF / ETF) per report date",
48
+ endpoint: "fund.top10-holders",
49
+ fields: [code(), ...range("Report date"), format(), output()],
50
+ });
51
+ query(fund, "asset-allocation", {
52
+ description: "Asset allocation per report date: amount and % of total assets by asset type",
53
+ endpoint: "fund.asset-allocation",
54
+ fields: [code(), ...range("Report date"), list("--asset-level <level>", "level1 / level2 (repeatable); omit for both", "assetLevelList"), format(), output()],
55
+ });
56
+ query(fund, "stock-portfolio", {
57
+ description: "Stock holdings per report date: shares, value, % of float and of NAV, change vs the previous report",
58
+ endpoint: "fund.stock-portfolio",
59
+ fields: [code(), ...range("Report date"), positionType(), format(), output()],
60
+ });
61
+ query(fund, "industry-allocation", {
62
+ description: "Stock holdings grouped by level-1 industry (SW or CITIC), as % of NAV",
63
+ endpoint: "fund.industry-allocation",
64
+ fields: [code(), ...range("Report date"), value("--industry-standard <name>", "swIndustry = SW level 1 (server default) / citicIndustry = CITIC level 1", "industryStandard"), positionType(), format(), output()],
65
+ });
66
+ query(fund, "bond-portfolio", {
67
+ description: "Bond holdings per report date: quantity, value, % of NAV",
68
+ endpoint: "fund.bond-portfolio",
69
+ fields: [code(), ...range("Report date"), format(), output()],
70
+ });
71
+ query(fund, "bond-type-allocation", {
72
+ description: "Bond holdings grouped by bond type, as % of NAV",
73
+ endpoint: "fund.bond-type-allocation",
74
+ fields: [code(), ...range("Report date"), format(), output()],
75
+ });
76
+ query(fund, "fund-portfolio", {
77
+ description: "Fund-of-funds holdings of other funds: value and % of NAV",
78
+ endpoint: "fund.fund-portfolio",
79
+ fields: [code(), ...range("Report date"), format(), output()],
80
+ });
81
+ query(fund, "fund-type-allocation", {
82
+ description: "Fund-of-funds holdings grouped by fund category (level 1), as % of NAV",
83
+ endpoint: "fund.fund-type-allocation",
84
+ fields: [code(), ...range("Report date"), format(), output()],
85
+ });
86
+ query(fund, "etf-pcf-header", {
87
+ description: "ETF creation/redemption parameters for the latest trade date: unit size, daily limits, cash components",
88
+ endpoint: "fund.etf-pcf-header",
89
+ fields: [code(), format(), output()],
90
+ });
91
+ query(fund, "etf-pcf-components", {
92
+ description: "ETF creation/redemption component list for the latest trade date: quantities, weights, cash substitution",
93
+ endpoint: "fund.etf-pcf-components",
94
+ fields: [code(), format(), output()],
95
+ });
96
+ query(fund, "etf-share-change", {
97
+ description: "ETF shares and scale per trade date, with the change vs the previous one",
98
+ endpoint: "fund.etf-share-change",
99
+ fields: [code(), ...range("Trade date"), format(), output()],
100
+ });
@@ -1,5 +1,6 @@
1
1
  import { Command, Option } from "commander";
2
2
  import { beijingDateString, collectList, dateArg, maybeArray, parseOptionalNumberOption } from "../core/args.js";
3
+ import { COSTLY_FETCH_CREDITS } from "../core/endpoints.js";
3
4
  import { normalizeRows } from "../core/normalize.js";
4
5
  import { parseOutputFormat } from "../core/output.js";
5
6
  import { printData } from "../core/printer.js";
@@ -128,6 +129,7 @@ fundamental.command("earning-forecast")
128
129
  .option("--start-date <date>", "Start date (default: 1 year before end-date)", dateArg("--start-date"))
129
130
  .option("--end-date <date>", "End date (default: today)", dateArg("--end-date"))
130
131
  .option("--consensus <name>", "Consensus indicator: netIncome/netIncomeYoy/eps/pe/bps/pb/peg/roe/ps", collectList, [])
132
+ .option("--yes", `Fetch a range estimated above ${COSTLY_FETCH_CREDITS} credits (billed per row; see the refusal for the estimate)`)
131
133
  .option("--format <format>", "Output format", "table")
132
134
  .option("--output <path>")
133
135
  .action((options) => emit(options, (client) => {
@@ -0,0 +1,14 @@
1
+ import { ai } from "./ai.js";
2
+ import { alternative } from "./alternative.js";
3
+ import { auth, lookup } from "./auth.js";
4
+ import { bond } from "./bond.js";
5
+ import { fund } from "./fund.js";
6
+ import { fundamental } from "./fundamental.js";
7
+ import { indicator } from "./indicator.js";
8
+ import { insight } from "./insight.js";
9
+ import { quote } from "./quote.js";
10
+ import { raw } from "./raw.js";
11
+ import { reference } from "./reference.js";
12
+ import { tool } from "./tool.js";
13
+ import { vault } from "./vault.js";
14
+ export const COMMAND_GROUPS = [auth, lookup, insight, quote, fundamental, bond, fund, reference, vault, ai, alternative, indicator, tool, raw];
@@ -1,5 +1,5 @@
1
1
  import { Command, Option } from "commander";
2
- import { collectList, dateArg, screenerExpressionFields, parseNumberOption, parseScreenerIndicators } from "../core/args.js";
2
+ import { collectList, collectNames, dateArg, screenerExpressionFields, parseNumberOption, parseScreenerIndicators } from "../core/args.js";
3
3
  import { buildIndicatorCrossSectionBody, buildIndicatorScreenerBody, buildIndicatorTimeSeriesBody } from "../core/commandBodies.js";
4
4
  import { checkScreenerBindings, droppedFromMatrix, flattenCrossSection, flattenTimeSeries, isEmptyMatrix, requireIndicatorMatrix, unwrapIndicatorData } from "../core/indicatorMatrix.js";
5
5
  import { resolveCalendarType } from "../core/calendarType.js";
@@ -56,7 +56,7 @@ indicator.command("cross-section")
56
56
  .requiredOption("--date <date>", "Data date (yyyy-MM-dd); sent as each indicator's tradeDate. Report-period indicators (is_*, financial statements) REJECT tradeDate and require --indicator-param 'code:reportDate=yyyy-MM-dd' instead — check parameterList in 'indicator search'", dateArg("--date"))
57
57
  .option("--currency <code>", "Currency: DFT/CNY/HKD/USD/EUR/GBP/JPY/TWD/MOP/AUD (default DFT)")
58
58
  .option("--scale <code>", "Scale: 0=个 3=千 4=万 6=百万 8=亿 9=十亿 (default 0)")
59
- .option("--indicator-param <spec>", "Per-indicator param 'code:key=value', e.g. qte_close:adjustType=2 for 前复权 (repeat); read exact keys from 'indicator search'. Bare 'code:' (nothing after the colon) declares the indicator takes NO date — required by any indicator whose parameterList has no date key at all, which otherwise rejects the tradeDate --date injects: the pty_* / scr_* static-attribute families (pty_op_scope, scr_exchg_mkt, scr_isin …), plus div_cash_paid_ratio / div_cash_yr (add 'code:fiscalYear=YYYY' too) and pty_shr_reg. It composes with real params, so 'code:' + 'code:scale=8' keeps the scale", collectList, [])
59
+ .option("--indicator-param <spec>", "Per-indicator param 'code:key=value', e.g. qte_close:adjustType=2 for 前复权 (repeat); read exact keys from 'indicator search'. Bare 'code:' (nothing after the colon) declares the indicator takes NO date — required by any indicator whose parameterList has no date key at all, which otherwise rejects the tradeDate --date injects: the pty_* / scr_* static-attribute families (pty_op_scope, scr_exchg_mkt, scr_isin …), plus div_cash_paid_ratio / div_cash_yr (add 'code:fiscalYear=YYYY' too) and pty_shr_reg. It composes with real params, so 'code:' + 'code:scale=8' keeps the scale", collectNames, [])
60
60
  .addOption(new Option("--key-by <mode>", "Column key: name=display name (default) | code=indicatorCode, unique & order-stable for batch code→value mapping").choices(["name", "code"]).default("name"))
61
61
  .option("--format <format>", "Output format", "table")
62
62
  .option("--output <path>")
@@ -77,7 +77,7 @@ indicator.command("time-series")
77
77
  .option("--calendar-type <type>", "Calendar: ND=natural TD=trading WD=weekday. Omit it and the CLI picks: a free 'indicator search' reads each parameterList and sends TD only when EVERY indicator is trading-day typed, otherwise it leaves the server on ND (report-period indicators land on dates a trading calendar lacks, and TD would answer them with an all-null grid). An explicit value is sent as given, with no lookup")
78
78
  .option("--currency <code>", "Currency: DFT/CNY/HKD/USD/EUR/GBP/JPY/TWD/MOP/AUD (default DFT)")
79
79
  .option("--scale <code>", "Scale: 0=个 3=千 4=万 6=百万 8=亿 9=十亿 (default 0)")
80
- .option("--indicator-param <spec>", "Per-indicator param 'code:key=value', e.g. qte_close:adjustType=2 for 前复权 (repeat); read exact keys from 'indicator search'", collectList, [])
80
+ .option("--indicator-param <spec>", "Per-indicator param 'code:key=value', e.g. qte_close:adjustType=2 for 前复权 (repeat); read exact keys from 'indicator search'", collectNames, [])
81
81
  .addOption(new Option("--key-by <mode>", "Column key: name=display name (default) | code=indicatorCode/securityCode, unique & order-stable for batch mapping").choices(["name", "code"]).default("name"))
82
82
  .option("--format <format>", "Output format", "table")
83
83
  .option("--output <path>")
@@ -100,11 +100,11 @@ indicator.command("time-series")
100
100
  }));
101
101
  indicator.command("screener")
102
102
  .description("Screen securities by an expression over indicator values (条件选股)")
103
- .option("--indicator <spec>", "Bind a variable to an indicator, 'F1:code', e.g. F1:qte_mkt_cptl (REQUIRED, repeat)", collectList, [])
103
+ .option("--indicator <spec>", "Bind a variable to an indicator, 'F1:code', e.g. F1:qte_mkt_cptl (REQUIRED, repeat)", collectNames, [])
104
104
  .option("--security <code>", "Security code, e.g. 600519.SH, or a sector ID from 'gangtise reference sector-search' (REQUIRED, repeat; union, deduped)", collectList, [])
105
105
  .requiredOption("--expression <expr>", "Filter over the bound variables, e.g. 'F1 >= 800 && (F2 >= 20 && F2 <= 30)'; also supports contains/notcontains on string indicators")
106
106
  .requiredOption("--date <date>", "Data date (yyyy-MM-dd); sent as every indicator's tradeDate unless it already has one. Report-period indicators (is_*) reject tradeDate: give them --indicator-param 'F1:reportDate=yyyy-MM-dd'", dateArg("--date"))
107
- .option("--indicator-param <spec>", "Per-variable param 'F1:key=value', e.g. F1:scale=8 (repeat); read exact keys from 'indicator search'. Bare 'F1:' (nothing after the colon) declares that the indicator takes NO date — required by any indicator whose parameterList has no date key at all, which otherwise rejects the tradeDate --date injects: the pty_* / scr_* static-attribute families (pty_op_scope, scr_exchg_sctr, scr_isin …), plus div_cash_paid_ratio / div_cash_yr (add 'F1:fiscalYear=YYYY' too) and pty_shr_reg. It composes with real params, so 'F1:' + 'F1:scale=8' keeps the scale", collectList, [])
107
+ .option("--indicator-param <spec>", "Per-variable param 'F1:key=value', e.g. F1:scale=8 (repeat); read exact keys from 'indicator search'. Bare 'F1:' (nothing after the colon) declares that the indicator takes NO date — required by any indicator whose parameterList has no date key at all, which otherwise rejects the tradeDate --date injects: the pty_* / scr_* static-attribute families (pty_op_scope, scr_exchg_sctr, scr_isin …), plus div_cash_paid_ratio / div_cash_yr (add 'F1:fiscalYear=YYYY' too) and pty_shr_reg. It composes with real params, so 'F1:' + 'F1:scale=8' keeps the scale", collectNames, [])
108
108
  .addOption(new Option("--key-by <mode>", "Column key: name=display name (default) | code=indicatorCode").choices(["name", "code"]).default("name"))
109
109
  .option("--format <format>", "Output format", "table")
110
110
  .option("--output <path>")
@@ -1,11 +1,10 @@
1
1
  import { Command } from "commander";
2
- import { collectList, dateArg, maybeArray, parseChoiceList, parseFrom, parseSize, parseTimestamp13 } from "../core/args.js";
2
+ import { collectList, dateArg, maybeArray, parseChoiceList, parseEndTimestamp13, parseFrom, parseSize, parseTimestamp13 } from "../core/args.js";
3
3
  import { ValidationError } from "../core/errors.js";
4
4
  import { fetchOpinionDetails } from "../core/opinionDetail.js";
5
5
  import { rowCount } from "../core/rowSink.js";
6
- import { emit, addDownloadCommand, confirmCostly, multiChoice, value, required, list, numberList, choiceList, count, rankType, searchType, top, flag, format, output, from, size, startTime, endTime, timeFilters, query } from "./shared.js";
6
+ import { emit, addDownloadCommand, confirmCostly, multiChoice, value, required, list, numberList, choiceList, count, rankType, searchType, top, flag, format, output, from, size, startTime, endTime, timeFilters, query, RESEARCH_AREA_CITIC_OR_DIRECTION } from "./shared.js";
7
7
  export const insight = new Command("insight").description("Insight APIs");
8
- const RESEARCH_AREA_CITIC_OR_DIRECTION = "Research area ID: citicIndustry code (1008001xx) or gangtiseIndustry direction code (122000xxx: macro/strategy/fixed-income/quant/overseas). swIndustry (104xx0000) returns 0 here";
9
8
  const SW_INDUSTRY_ONLY = "Industry ID -- swIndustry codes only (104xx0000); citicIndustry codes are rejected with 100005 even where constant-category declares them";
10
9
  const opinion = new Command("opinion");
11
10
  const summary = new Command("summary");
@@ -204,7 +203,7 @@ query(announcement, "list", {
204
203
  list("--security <code>", "Security code", "securityList"),
205
204
  list("--category <id>", "Category ID", "categoryList"),
206
205
  format(), output("Output path"),
207
- ...timeFilters(parseTimestamp13),
206
+ ...timeFilters(parseTimestamp13, parseEndTimestamp13),
208
207
  ],
209
208
  });
210
209
  addDownloadCommand(announcement, { endpointKey: "insight.announcement.download", idOption: "--announcement-id", idField: "announcementId", fallbackPrefix: "announcement", fileType: { description: "File type: 1=PDF 2=Markdown", choices: ["1", "2"], default: "1" }, titleListEndpoint: "insight.announcement.list" });
@@ -13,16 +13,17 @@ export const raw = new Command("raw").description("Raw API calls").addCommand(ne
13
13
  }
14
14
  assertConfirmed(endpointKey, Boolean(options.yes), endpointKey);
15
15
  const format = parseOutputFormat(options.format);
16
- const client = await createClient({ format, output: options.output, yes: Boolean(options.yes) });
17
16
  let body;
18
17
  if (options.body) {
19
18
  try {
20
19
  body = JSON.parse(options.body);
21
20
  }
22
- catch {
23
- throw new ConfigError(`Invalid JSON in --body: ${options.body}`);
21
+ catch (error) {
22
+ const position = /position (\d+)/.exec(error instanceof Error ? error.message : "")?.[1];
23
+ throw new ConfigError(`Invalid JSON in --body${position === undefined ? "" : ` (at character ${position})`}; the body is not echoed because it may hold credentials`);
24
24
  }
25
25
  }
26
+ const client = await createClient({ format, output: options.output, yes: Boolean(options.yes) });
26
27
  if (endpoint.kind === "download") {
27
28
  if (body !== undefined && endpoint.method !== "POST") {
28
29
  throw new ValidationError(`--body is not supported for GET download endpoints (use --query key=value); ${endpointKey} is kind=download`);
@@ -1,11 +1,13 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
1
3
  import { Option } from "commander";
2
4
  import { QUOTE_MAX_LIMIT } from "../core/quoteSharding.js";
3
5
  import { collectList, dateArg, datetimeArg, maybeArray, numberListArg, parseChoiceList, parseFrom, parseNumberOption, parseOptionalNumberOption, parseSize } from "../core/args.js";
4
6
  import { loadConfig } from "../core/config.js";
5
- import { releaseClaim, resolveTitle, saveDownloadResult, uniquePath } from "../core/download.js";
7
+ import { placeStreamedDownload, releaseClaim, resolveTitle, saveDownloadResult, uniquePath } from "../core/download.js";
6
8
  import { ENDPOINTS } from "../core/endpoints.js";
7
9
  import { ValidationError } from "../core/errors.js";
8
- import { parseOutputFormat } from "../core/output.js";
10
+ import { parseOutputFormat, stagingPath } from "../core/output.js";
9
11
  import { printData } from "../core/printer.js";
10
12
  import { ExportSink, getRowSink } from "../core/rowSink.js";
11
13
  export async function createClient(stream) {
@@ -91,7 +93,21 @@ export async function runDownload(client, endpointKey, query, options) {
91
93
  await saveDownloadResult(result, options.fallbackName, options.output);
92
94
  return;
93
95
  }
94
- const result = await client.call(endpointKey, options.body, query);
96
+ const temp = stagingPath(path.resolve(".gangtise-download"), "part");
97
+ const result = await client.call(endpointKey, options.body, query, { streamTo: temp });
98
+ const streamed = result;
99
+ if (typeof streamed?.savedPath === "string") {
100
+ let named;
101
+ try {
102
+ named = options.resolveOutputPath ? await options.resolveOutputPath(result) : undefined;
103
+ }
104
+ catch (error) {
105
+ await fs.unlink(streamed.savedPath).catch(() => { });
106
+ throw error;
107
+ }
108
+ process.stdout.write(`${await placeStreamedDownload({ ...streamed, savedPath: streamed.savedPath }, options.fallbackName, named)}\n`);
109
+ return;
110
+ }
95
111
  const resolved = options.resolveOutputPath ? await options.resolveOutputPath(result) : undefined;
96
112
  const target = resolved ? await uniquePath(resolved) : undefined;
97
113
  try {
@@ -139,8 +155,9 @@ export function assertConfirmed(endpointKey, confirmed, target) {
139
155
  return;
140
156
  throw new ValidationError(`${destructive.warning} 确认要对 ${target} 执行就加上 --yes。`);
141
157
  }
158
+ export const RESEARCH_AREA_CITIC_OR_DIRECTION = "Research area ID: citicIndustry code (1008001xx) or gangtiseIndustry direction code (122000xxx: macro/strategy/fixed-income/quant/overseas). swIndustry (104xx0000) returns 0 here";
142
159
  export const field = (option, body) => ({ option, body });
143
- export const confirmCostly = () => field(new Option("--yes", "Fetch every row even when a fetch without --size is estimated above the credit guard (the list is billed per row)"));
160
+ export const confirmCostly = () => field(new Option("--yes", "Fetch past the credit guard: a fetch without --size, or with a large one, estimated above 1000 credits (the list is billed per row)"));
144
161
  export const value = (flags, description, key) => field(new Option(flags, description), (v) => ({ [key]: v }));
145
162
  export const required = (flags, description, key) => field(new Option(flags, description).makeOptionMandatory(), (v) => ({ [key]: v }));
146
163
  export const date = (flags, description, key, opts = {}) => {
@@ -149,6 +166,14 @@ export const date = (flags, description, key, opts = {}) => {
149
166
  return field(opts.required ? option.makeOptionMandatory() : option, (v) => ({ [key]: v }));
150
167
  };
151
168
  export const list = (flags, description, key) => field(new Option(flags, description).argParser(collectList).default([]), (v) => ({ [key]: maybeArray(v) }));
169
+ export const requiredList = (flags, description, key, parse = collectList) => {
170
+ const option = new Option(flags, description).argParser(parse).makeOptionMandatory();
171
+ return field(option, (v) => {
172
+ if (!v.length)
173
+ throw new ValidationError(`${option.long} is empty: pass at least one value`);
174
+ return { [key]: v };
175
+ });
176
+ };
152
177
  export const numberList = (flags, description, key) => {
153
178
  const option = new Option(flags, description).default([]);
154
179
  option.argParser(numberListArg(option.long));
@@ -183,7 +208,7 @@ export const size = (description = "Total rows to return; omit to fetch all") =>
183
208
  const asTyped = (v) => v;
184
209
  export const startTime = (description = "Start time", toBody = asTyped) => field(new Option("--start-time <datetime>", description).argParser(datetimeArg("--start-time")), (v) => ({ startTime: toBody(v, "--start-time") }));
185
210
  export const endTime = (description = "End time", toBody = asTyped) => field(new Option("--end-time <datetime>", description).argParser(datetimeArg("--end-time")), (v) => ({ endTime: toBody(v, "--end-time") }));
186
- export const timeFilters = (toBody = asTyped) => [from(), size(), startTime("Start time", toBody), endTime("End time", toBody), value("--keyword <keyword>", "Keyword", "keyword")];
211
+ export const timeFilters = (toBody = asTyped, toEndBody = toBody) => [from(), size(), startTime("Start time", toBody), endTime("End time", toEndBody), value("--keyword <keyword>", "Keyword", "keyword")];
187
212
  export const requestBody = (fields, options) => Object.assign({}, ...fields.map((f) => f.body?.(options[f.option.attributeName()]) ?? {}));
188
213
  export function query(parent, name, spec) {
189
214
  const command = parent.command(name);
@@ -193,5 +218,9 @@ export function query(parent, name, spec) {
193
218
  const fields = defaultEndpoint?.pagination?.enabled && defaultEndpoint.billing?.per === "row" ? [...spec.fields, confirmCostly()] : spec.fields;
194
219
  for (const f of fields)
195
220
  command.addOption(f.option);
196
- return command.action((options) => emit(options, (client) => client.call(typeof spec.endpoint === "string" ? spec.endpoint : spec.endpoint(options), requestBody(fields, options)), spec.cache));
221
+ return command.action(async (options) => {
222
+ const body = requestBody(fields, options);
223
+ const endpoint = typeof spec.endpoint === "string" ? spec.endpoint : spec.endpoint(options);
224
+ return emit(options, (client) => client.call(endpoint, body), spec.cache);
225
+ });
197
226
  }
@@ -33,7 +33,7 @@ tool.command("file-parse-check").description("Download a finished file-parse res
33
33
  }));
34
34
  tool.command("web-search").description("Search the public web for research: deduped, source-tiered (T0-T3) results with optional page content (1 credit/call)")
35
35
  .requiredOption("--query <text>", "Search query, 1-200 chars. Sent verbatim — no intent rewriting server-side, so spell out what you want")
36
- .option("--size <number>", "Results to return, 1-20 (1-5 with --include-content)", "10")
36
+ .option("--size <number>", "Results to return, 1-20 (1-5 with --include-content); default 10, or 5 with --include-content")
37
37
  .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"]))
38
38
  .addOption(new Option("--min-tier <tier>", "Lowest source tier to keep; T3 = no filtering").choices(["T0", "T1", "T2", "T3"]))
39
39
  .option("--site <domain>", "Restrict to a registered domain or subdomain, e.g. csrc.gov.cn (repeatable, max 10, OR-ed)", collectList, [])
@@ -41,7 +41,7 @@ tool.command("web-search").description("Search the public web for research: dedu
41
41
  .option("--max-content-chars <number>", "Max chars per body, 1000-20000 (only with --include-content)")
42
42
  .option("--format <format>", "Output format", "table").option("--output <path>")
43
43
  .action((options) => emit(options, (client) => {
44
- const size = parseNumberOption(options.size, "--size", { integer: true, min: 1, max: 20 });
44
+ const size = parseNumberOption(options.size ?? (options.includeContent ? "5" : "10"), "--size", { integer: true, min: 1, max: 20 });
45
45
  if (options.includeContent && size > 5)
46
46
  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.`);
47
47
  const sites = [...new Set(options.site)];
@@ -1,9 +1,8 @@
1
1
  import { Command, Option } from "commander";
2
- import { collectList, datetimeArg } from "../core/args.js";
3
- import { buildStockPoolStocksBody, buildWechatChatroomListBody, buildWechatMessageListBody } from "../core/commandBodies.js";
2
+ import { collectList, collectNames } from "../core/args.js";
4
3
  import { uploadDriveFile } from "../core/driveUpload.js";
5
4
  import { flagFailedItems } from "../core/normalize.js";
6
- import { emit, addDownloadCommand, assertConfirmed, field, value, required, list, numberList, format, output, from, size, startTime, endTime, query } from "./shared.js";
5
+ import { emit, addDownloadCommand, assertConfirmed, field, value, required, list, numberList, format, output, from, size, startTime, endTime, query, RESEARCH_AREA_CITIC_OR_DIRECTION } from "./shared.js";
7
6
  export const vault = new Command("vault").description("Vault APIs");
8
7
  query(vault, "drive-list", {
9
8
  endpoint: "vault.drive.list",
@@ -117,7 +116,7 @@ query(vault, "my-conference-list", {
117
116
  cache: { endpointKey: "vault.my-conference.list", idField: "conferenceId" },
118
117
  fields: [
119
118
  from(), size(), startTime(), endTime(), value("--keyword <text>", undefined, "keyword"),
120
- list("--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", "researchAreaList"),
119
+ list("--research-area <id>", RESEARCH_AREA_CITIC_OR_DIRECTION, "researchAreaList"),
121
120
  list("--security <code>", "Security code", "securityList"),
122
121
  list("--institution <id>", "Institution ID", "institutionList"),
123
122
  list("--category <name>", "Conference category: earningsCall/strategyMeeting/fundRoadshow/shareholdersMeeting/maMeeting/specialMeeting/companyAnalysis/industryAnalysis/other", "categoryList"),
@@ -126,33 +125,34 @@ query(vault, "my-conference-list", {
126
125
  ],
127
126
  });
128
127
  addDownloadCommand(vault, { endpointKey: "vault.my-conference.download", name: "my-conference-download", idOption: "--conference-id", idField: "conferenceId", fallbackPrefix: "conference", contentTypeDescription: "Content type: asr/summary", contentTypeChoices: ["asr", "summary"], titleListEndpoint: "vault.my-conference.list" });
129
- vault.command("wechat-message-list")
130
- .option("--from <number>", "Starting offset", "0")
131
- .option("--size <number>", "Total rows to return; omit to fetch all")
132
- .option("--start-time <datetime>", "Start time", datetimeArg("--start-time"))
133
- .option("--end-time <datetime>", "End time", datetimeArg("--end-time"))
134
- .option("--keyword <text>")
135
- .option("--security <code>", "Security code (e.g. 000001.SZ)", collectList, [])
136
- .option("--wechat-group-id <id>", "WeChat group ID", collectList, [])
137
- .option("--industry <id>", "Industry ID -- citicIndustry codes (1008001xx) only; swIndustry codes and unknown values are rejected with 100005", collectList, [])
138
- .option("--category <name>", "Message type: text/image/documents/url", collectList, [])
139
- .option("--tag <name>", "Tag: roadShow/research/strategyMeeting/meetingSummary/industryComment/companyComment/earningsReview", collectList, [])
140
- .option("--format <format>", "Output format", "table")
141
- .option("--output <path>")
142
- .action((options) => emit(options, (client) => client.call("vault.wechat-message.list", buildWechatMessageListBody(options))));
143
- vault.command("wechat-chatroom-list")
144
- .option("--from <number>", "Starting offset", "0")
145
- .option("--size <number>", "Total rows to return; omit to fetch all")
146
- .option("--room-name <name>", "WeChat group name; repeat or comma-separate for multiple names", collectList, [])
147
- .option("--format <format>", "Output format", "table")
148
- .option("--output <path>")
149
- .action((options) => emit(options, (client) => client.call("vault.wechat-chatroom.list", buildWechatChatroomListBody(options))));
128
+ query(vault, "wechat-message-list", {
129
+ endpoint: "vault.wechat-message.list",
130
+ fields: [
131
+ from(), size(), startTime(), endTime(), value("--keyword <text>", undefined, "keyword"),
132
+ list("--security <code>", "Security code (e.g. 000001.SZ)", "securityList"),
133
+ list("--wechat-group-id <id>", "WeChat group ID", "wechatGroupIdList"),
134
+ list("--industry <id>", "Industry ID -- citicIndustry codes (1008001xx) only; swIndustry codes and unknown values are rejected with 100005", "industryIdList"),
135
+ list("--category <name>", "Message type: text/image/documents/url", "categoryList"),
136
+ list("--tag <name>", "Tag: roadShow/research/strategyMeeting/meetingSummary/industryComment/companyComment/earningsReview", "tagList"),
137
+ format(), output(),
138
+ ],
139
+ });
140
+ query(vault, "wechat-chatroom-list", {
141
+ endpoint: "vault.wechat-chatroom.list",
142
+ fields: [
143
+ from(), size(),
144
+ field(new Option("--room-name <name>", "WeChat group name; repeat or comma-separate for multiple names").argParser(collectNames).default([]), (v) => ({ roomName: v.length > 0 ? v.join(",") : undefined })),
145
+ format(), output(),
146
+ ],
147
+ });
150
148
  query(vault, "stock-pool-list", { endpoint: "vault.stock-pool.list", fields: [format(), output()] });
151
- vault.command("stock-pool-stocks")
152
- .option("--pool-id <id>", "Pool ID; repeat for multiple; omit (or 'all') for all pools", collectList)
153
- .option("--format <format>", "Output format", "table")
154
- .option("--output <path>")
155
- .action((options) => emit(options, (client) => client.call("vault.stock-pool.stocks", buildStockPoolStocksBody(options))));
149
+ query(vault, "stock-pool-stocks", {
150
+ endpoint: "vault.stock-pool.stocks",
151
+ fields: [
152
+ field(new Option("--pool-id <id>", "Pool ID; repeat for multiple; omit (or 'all') for all pools").argParser(collectList), (v) => ({ poolIdList: v?.length ? v : ["all"] })),
153
+ format(), output(),
154
+ ],
155
+ });
156
156
  query(vault, "stock-pool-create", {
157
157
  endpoint: "vault.stock-pool.create",
158
158
  fields: [
@@ -1,12 +1,18 @@
1
1
  import { ValidationError } from "./errors.js";
2
2
  export function splitCsv(value) {
3
3
  return value
4
- .split(/[,,]/)
4
+ .split(/[,,、;;\s]+/)
5
5
  .map((item) => item.trim())
6
6
  .filter(Boolean);
7
7
  }
8
8
  export function collectList(value, previous = []) {
9
- return [...previous, ...splitCsv(value)];
9
+ return unique([...previous, ...splitCsv(value)]);
10
+ }
11
+ function unique(values) {
12
+ return [...new Set(values)];
13
+ }
14
+ export function collectNames(value, previous = []) {
15
+ return unique([...previous, ...value.split(/[,,]/).map((item) => item.trim()).filter(Boolean)]);
10
16
  }
11
17
  export function collectText(value, previous = []) {
12
18
  const text = value.trim();
@@ -44,10 +50,12 @@ export function parseSize(value) {
44
50
  return parseOptionalNumberOption(value, "--size", { integer: true, min: 1 });
45
51
  }
46
52
  export function numberListArg(optionName) {
47
- return (value, previous = []) => [
48
- ...previous,
49
- ...splitCsv(value).map((item) => parseNumberOption(item, optionName, { integer: true })),
50
- ];
53
+ const parse = (value, previous = []) => [...new Set([
54
+ ...previous,
55
+ ...splitCsv(value).map((item) => parseNumberOption(item, optionName, { integer: true })),
56
+ ])];
57
+ ACCUMULATING_PARSERS.add(parse);
58
+ return parse;
51
59
  }
52
60
  export function collectKeyValue(value, previous = {}) {
53
61
  const index = value.indexOf("=");
@@ -155,6 +163,13 @@ export function toTimestamp13(value) {
155
163
  const instant = new Date(`${y}-${mo}-${d}T${hh}:${mi}:${ss}${BEIJING_OFFSET}`).getTime();
156
164
  return Number.isNaN(instant) ? undefined : instant;
157
165
  }
166
+ export function parseEndTimestamp13(value, optionName) {
167
+ const parsed = parseTimestamp13(value, optionName);
168
+ if (parsed === undefined || value === undefined)
169
+ return parsed;
170
+ const parts = LOCAL_DATETIME.exec(value);
171
+ return parts && parts[5] === undefined ? parsed + 86_400_000 - 1 : parsed;
172
+ }
158
173
  export function parseTimestamp13(value, optionName) {
159
174
  if (value === undefined)
160
175
  return undefined;
@@ -204,7 +219,7 @@ function parseParamSpecs(specs, option, syntax) {
204
219
  params = [];
205
220
  groups.set(lhs, params);
206
221
  }
207
- params.push({ paramKey, paramValue });
222
+ params.push({ paramKey, paramValue: /date$/i.test(paramKey) ? parseDateOption(paramValue, `${option} "${spec}"`) : paramValue });
208
223
  }
209
224
  return { groups, noQueryDate };
210
225
  }
@@ -322,3 +337,4 @@ export function parseScreenerIndicators(bindings, paramSpecs, expression) {
322
337
  }
323
338
  return [...indicators.values()];
324
339
  }
340
+ export const ACCUMULATING_PARSERS = new WeakSet([collectList, collectNames, collectText, collectKeyValue]);
@@ -23,7 +23,7 @@ function terminalFailureLine(error) {
23
23
  const trace = api?.traceId ? ` [trace ${api.traceId}]` : "";
24
24
  const detail = api?.message ? `: ${api.message}` : "";
25
25
  return `Content generation failed (terminal${code})${trace}${detail}\n`
26
- + "This dataId is final — re-checking it will not change. Resubmitting the generation task bills again for the same result; change the parameters first.\n";
26
+ + "This dataId is final — re-checking it will not change. Resubmitting the generation task bills again: if the message above points at a parameter or the content, change that first; a plain 业务处理失败 is not about the parameters — resubmit later only if you still need it.\n";
27
27
  }
28
28
  export async function pollUntilDone(attempt, notReady) {
29
29
  for (let n = 1; n <= POLL_MAX_ATTEMPTS; n++) {
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { ConfigError } from "./errors.js";
5
- import { stagingPath } from "./output.js";
5
+ import { writeFileAtomic } from "./output.js";
6
6
  export function credentialFingerprint(accessKey, baseUrl) {
7
7
  return createHash("sha256").update(`${accessKey}\u0000${baseUrl}`).digest("hex").slice(0, 16);
8
8
  }
@@ -31,15 +31,7 @@ export function redactTokenCache(cache) {
31
31
  }
32
32
  export async function writeTokenCache(filePath, cache) {
33
33
  await fs.mkdir(path.dirname(filePath), { recursive: true });
34
- const tmp = stagingPath(filePath, "tmp");
35
- await fs.writeFile(tmp, JSON.stringify(cache, null, 2), { encoding: "utf8", mode: 0o600 });
36
- try {
37
- await fs.rename(tmp, filePath);
38
- }
39
- catch (error) {
40
- await fs.unlink(tmp).catch(() => { });
41
- throw error;
42
- }
34
+ await writeFileAtomic(filePath, JSON.stringify(cache, null, 2), { mode: 0o600, suffix: "tmp" });
43
35
  }
44
36
  export function isTokenCacheValid(cache, bufferSeconds = 300, expectedFingerprint) {
45
37
  if (!cache?.accessToken || !cache.expiresAt) {