@investoday/investoday-api 1.1.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,10 +10,10 @@ npm install -g @investoday/investoday-api
10
10
 
11
11
  ## API key
12
12
 
13
- Set your API key with the `INVESTODAY_API_KEY` environment variable:
13
+ Configure the API key once:
14
14
 
15
15
  ```bash
16
- export INVESTODAY_API_KEY="<your_key>"
16
+ investoday-api init
17
17
  ```
18
18
 
19
19
  Get an API key from:
@@ -24,12 +24,13 @@ Get an API key from:
24
24
 
25
25
  ```bash
26
26
  investoday-api --help
27
+ investoday-api init
28
+ investoday-api config status
27
29
  investoday-api list
28
30
  investoday-api list 沪深京数据
29
- investoday-api schema stock/basic-info
30
- investoday-api example stock/basic-info
31
- investoday-api search-api 违规处罚
32
- investoday-api search-api 股票 --text
31
+ investoday-api search-api query=违规处罚 tool_ids=list_stock_violation_penalt
32
+ investoday-api search-api query=股票,基本面分析
33
+ investoday-api search-api query=股票 --text
33
34
  investoday-api <endpoint> [key=value ...]
34
35
  investoday-api <endpoint> --method POST [key=value ...]
35
36
  ```
@@ -38,10 +39,10 @@ Examples:
38
39
 
39
40
  ```bash
40
41
  investoday-api list 沪深京数据/股票行情
41
- investoday-api schema stock/violation-penalties
42
- investoday-api example stock/violation-penalties
43
- investoday-api search-api stockCodes
44
- investoday-api search-api 股票 --text
42
+ investoday-api search-api query=stockCodes
43
+ investoday-api search-api query=股票,基本面分析
44
+ investoday-api search-api tool_ids=list_stock_violation_penalt,list_stock_report_schema
45
+ investoday-api search-api query=股票 --text
45
46
  investoday-api stock/basic-info stockCode=600519
46
47
  investoday-api search key=贵州茅台 type=11
47
48
  investoday-api fund/daily-quotes --method POST fundCode=000001 beginDate=2024-01-01 endDate=2024-12-31
@@ -49,8 +50,9 @@ investoday-api fund/daily-quotes --method POST fundCode=000001 beginDate=2024-01
49
50
 
50
51
  ## Notes
51
52
 
52
- - Only reads credentials from `INVESTODAY_API_KEY`
53
+ - Uses the local encrypted config created by `investoday-api init`
53
54
  - Only calls `https://data-api.investoday.net/data`
54
- - Bundles endpoint metadata for `list`, `schema`, `example`, and `search-api`
55
- - `search-api` defaults to JSON output; use `--text` for a human-readable summary
55
+ - Bundles endpoint metadata for `list` and `search-api`
56
+ - `search-api` defaults to JSON output and includes params, response fields, and `exampleCommand`; use `--text` for a human-readable summary
57
+ - `search-api` only accepts structured inputs such as `query=` and `tool_ids=`; `query=` accepts one value and supports comma-separated keywords
56
58
  - Prints the API response `data` field as formatted JSON
package/lib/call-api.js CHANGED
@@ -1,7 +1,18 @@
1
1
  const { getMetadata, resolveApi, searchApis } = require("./metadata");
2
+ const {
3
+ CONFIG_DIR_ENV,
4
+ getConfigDir,
5
+ getCredentialsPath,
6
+ readCredentials,
7
+ removeCredentials,
8
+ resolveApiKey,
9
+ saveCredentials,
10
+ } = require("./config");
11
+ const { version: PACKAGE_VERSION } = require("../package.json");
2
12
 
3
13
  const BASE_URL = "https://data-api.investoday.net/data";
4
14
  const REQUEST_TIMEOUT = 30_000;
15
+ const API_KEY_MANAGE_URL = "https://data-api.investoday.net/user/api-key";
5
16
 
6
17
  function exitWithError(message) {
7
18
  process.stderr.write(`${message}\n`);
@@ -9,40 +20,203 @@ function exitWithError(message) {
9
20
  }
10
21
 
11
22
  function loadApiKey() {
12
- const envKey = (process.env.INVESTODAY_API_KEY || "").trim();
13
- if (envKey) {
14
- return envKey;
23
+ const { apiKey } = resolveApiKey();
24
+ if (apiKey) {
25
+ return apiKey;
15
26
  }
16
27
 
17
- exitWithError("ERROR: Please set the INVESTODAY_API_KEY environment variable first.");
28
+ exitWithError(
29
+ "ERROR: 运行环境配置失败,请使用终端Bash运行`investoday-api init`配置。"
30
+ );
18
31
  }
19
32
 
20
33
  function printHelp() {
21
34
  process.stdout.write(
22
35
  "investoday-api\n\n" +
23
36
  "Usage:\n" +
37
+ " investoday-api init\n" +
38
+ " investoday-api config status|path|remove\n" +
24
39
  " investoday-api <endpoint> [key=value ...] [--method GET|POST]\n" +
25
40
  " investoday-api list [group-or-subgroup]\n" +
26
- " investoday-api schema <endpoint|tool_id|api_name>\n" +
27
- " investoday-api example <endpoint|tool_id|api_name>\n" +
28
- " investoday-api search-api <query> [--text]\n" +
41
+ " investoday-api search-api query=<query> [tool_ids=<tool_id,...>] [--text]\n" +
42
+ " investoday-api --version\n" +
29
43
  " investoday-api --help\n\n" +
30
44
  "Commands:\n" +
45
+ " init Initialize local InvestToday API key configuration\n" +
46
+ " config Show, locate, or remove the local encrypted credentials\n" +
31
47
  " list List available groups, subgroups, or endpoints from bundled metadata\n" +
32
- " schema Show method, parameters, and response fields for one endpoint\n" +
33
- " example Show a runnable command example for one endpoint\n\n" +
34
- " search-api Search endpoints by name, path, group, request params, or response fields\n\n" +
48
+ " search-api Search endpoints and return method, params, response fields, and example command\n\n" +
35
49
  "Examples:\n" +
50
+ " investoday-api init\n" +
51
+ " investoday-api config status\n" +
36
52
  " investoday-api list\n" +
37
53
  " investoday-api list 沪深京数据\n" +
38
- " investoday-api schema stock/basic-info\n" +
39
- " investoday-api example stock/violation-penalties\n" +
40
- " investoday-api search-api 违规处罚\n" +
41
- " investoday-api search-api 股票 --text\n" +
42
- " investoday-api stock/basic-info stockCode=600519\n"
54
+ " investoday-api list 沪深京数据/公司行为/基本信息\n" +
55
+ " investoday-api search-api query=股票,基本面分析\n" +
56
+ " investoday-api search-api tool_ids=list_stock_violation_penalt,list_stock_report_schema\n" +
57
+ " investoday-api search-api query=股票 --text\n" +
58
+ " investoday-api search key=贵州茅台 type=11\n" +
59
+ " investoday-api stock/basic-info stockCode=600519\n" +
60
+ " investoday-api fund/daily-quotes --method POST fundCode=000001 beginDate=2024-01-01 endDate=2024-12-31\n"
61
+ );
62
+ }
63
+
64
+ function printVersion() {
65
+ process.stdout.write(`${PACKAGE_VERSION}\n`);
66
+ }
67
+
68
+ function hasArg(args, name) {
69
+ return args.includes(name);
70
+ }
71
+
72
+ function printInitHelp() {
73
+ process.stdout.write(
74
+ "Usage:\n" +
75
+ " investoday-api init\n\n" +
76
+ "Default flow:\n" +
77
+ " Prompt for InvestToday API Key, verify it, then save it to local encrypted config.\n\n" +
78
+ "Options:\n" +
79
+ " --skip-verify Save without validating the API key first\n\n" +
80
+ `Local config: ${getCredentialsPath()}\n`
81
+ );
82
+ }
83
+
84
+ function askInput(query) {
85
+ const readline = require("node:readline");
86
+ const rl = readline.createInterface({
87
+ input: process.stdin,
88
+ output: process.stderr,
89
+ });
90
+
91
+ return new Promise((resolve) => {
92
+ rl.question(query, (answer) => {
93
+ rl.close();
94
+ resolve(answer);
95
+ });
96
+ });
97
+ }
98
+
99
+ async function resolveInitApiKey(args) {
100
+ if (!process.stdin.isTTY || !process.stderr.isTTY) {
101
+ exitWithError("ERROR: Interactive input is unavailable. Run 'investoday-api init' in an interactive terminal.");
102
+ }
103
+
104
+ const apiKey = await askInput("InvestToday API Key: ");
105
+ if (!String(apiKey || "").trim()) {
106
+ exitWithError("ERROR: API key cannot be empty.");
107
+ }
108
+ return apiKey.trim();
109
+ }
110
+
111
+ async function verifyApiKey(apiKey) {
112
+ const url = buildUrl("trade-calender/cn/is-trade-date", { tDate: "2025-12-22" });
113
+ let response;
114
+ try {
115
+ response = await fetch(url, {
116
+ method: "GET",
117
+ headers: { apiKey },
118
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT),
119
+ });
120
+ } catch (error) {
121
+ let message = String(error.message || error);
122
+ if (apiKey && message.includes(apiKey)) {
123
+ message = message.replaceAll(apiKey, "***");
124
+ }
125
+ throw new Error(`verification request failed: ${message}`);
126
+ }
127
+
128
+ if (!response.ok) {
129
+ const body = await response.text().catch(() => "");
130
+ throw new Error(`HTTP ${response.status}${body ? `: ${body.slice(0, 500)}` : ""}`);
131
+ }
132
+
133
+ const result = await response.json().catch(() => null);
134
+ if (!result || result.code !== 0) {
135
+ throw new Error(result && result.message ? result.message : "invalid response");
136
+ }
137
+ }
138
+
139
+ async function runInitCommand(args) {
140
+ if (hasArg(args, "--help") || hasArg(args, "-h")) {
141
+ printInitHelp();
142
+ return;
143
+ }
144
+
145
+ const allowed = new Set(["--skip-verify"]);
146
+ for (const arg of args) {
147
+ if (!allowed.has(arg)) {
148
+ exitWithError(`ERROR: Unknown init option '${arg}'`);
149
+ }
150
+ }
151
+
152
+ const apiKey = await resolveInitApiKey(args);
153
+ if (!hasArg(args, "--skip-verify")) {
154
+ process.stderr.write("Verifying InvestToday API key...\n");
155
+ try {
156
+ await verifyApiKey(apiKey);
157
+ } catch (error) {
158
+ exitWithError(
159
+ `InvestToday API Key verification failed.\n` +
160
+ `Error: ${error.message}\n` +
161
+ `请登录 ${API_KEY_MANAGE_URL} 确认您的apiKey正确。`
162
+ );
163
+ }
164
+ }
165
+
166
+ try {
167
+ saveCredentials(apiKey);
168
+ } catch (error) {
169
+ exitWithError(`ERROR: Failed to save credentials: ${error.message}`);
170
+ }
171
+
172
+ process.stdout.write(
173
+ "InvestToday API key 配置成功\n\n" +
174
+ "初始化完成 ✅\n"
43
175
  );
44
176
  }
45
177
 
178
+ function runConfigCommand(args) {
179
+ const action = args[0] || "status";
180
+ if (action === "--help" || action === "-h" || action === "help") {
181
+ process.stdout.write(
182
+ "Usage:\n" +
183
+ " investoday-api config status\n" +
184
+ " investoday-api config path\n" +
185
+ " investoday-api config remove\n\n" +
186
+ `Config dir env override: ${CONFIG_DIR_ENV}\n`
187
+ );
188
+ return;
189
+ }
190
+
191
+ if (action === "path") {
192
+ process.stdout.write(`${getCredentialsPath()}\n`);
193
+ return;
194
+ }
195
+
196
+ if (action === "remove") {
197
+ removeCredentials();
198
+ process.stdout.write("InvestToday local credentials removed.\n");
199
+ return;
200
+ }
201
+
202
+ if (action === "status") {
203
+ const localConfigured = Boolean(readCredentials());
204
+ const activeSource = resolveApiKey().source;
205
+ process.stdout.write(
206
+ JSON.stringify({
207
+ status: activeSource === "missing" ? "missing" : "configured",
208
+ localConfig: localConfigured ? "configured" : "missing",
209
+ activeSource,
210
+ configDir: getConfigDir(),
211
+ credentialsFile: getCredentialsPath(),
212
+ }, null, 2) + "\n"
213
+ );
214
+ return;
215
+ }
216
+
217
+ exitWithError(`ERROR: Unknown config action '${action}'. Use status, path, or remove.`);
218
+ }
219
+
46
220
  function normalizeExampleParameterKey(name) {
47
221
  return String(name || "").replace(/Codes$/, "Code").replace(/Ids$/, "Id").replace(/List$/, "");
48
222
  }
@@ -125,22 +299,6 @@ function printApiSuggestions(matches) {
125
299
  }
126
300
  }
127
301
 
128
- function getSingleApi(query) {
129
- const { matches, error } = resolveApi(query);
130
- if (error) {
131
- exitWithError(`ERROR: ${error}`);
132
- }
133
- if (!matches.length) {
134
- exitWithError(`ERROR: No endpoint matched '${query}'`);
135
- }
136
- if (matches.length > 1) {
137
- printApiSuggestions(matches);
138
- process.exit(1);
139
- }
140
-
141
- return matches[0];
142
- }
143
-
144
302
  function countEndpoints(node) {
145
303
  if (!node) {
146
304
  return 0;
@@ -262,7 +420,7 @@ function listGroup(query) {
262
420
  return;
263
421
  }
264
422
 
265
- const { matches: fuzzyMatches } = searchApis(normalizedQuery, 20);
423
+ const { matches: fuzzyMatches } = searchApis({ query: [normalizedQuery] }, 20);
266
424
 
267
425
  if (!fuzzyMatches.length) {
268
426
  exitWithError(`ERROR: No group or endpoint matched '${normalizedQuery}'`);
@@ -290,41 +448,6 @@ function runListCommand(args) {
290
448
  listGroup(args.join(" "));
291
449
  }
292
450
 
293
- function runSchemaCommand(args) {
294
- const api = getSingleApi(args.join(" "));
295
- const payload = {
296
- apiName: api.apiName,
297
- groupPath: api.groupPath,
298
- path: api.path,
299
- method: api.method,
300
- toolId: api.toolId,
301
- toolName: api.toolName,
302
- summary: api.summary,
303
- description: api.description,
304
- parameters: api.parameters,
305
- responseFields: api.responseFields,
306
- };
307
-
308
- process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
309
- }
310
-
311
- function runExampleCommand(args) {
312
- const api = getSingleApi(args.join(" "));
313
- const optionalParams = api.parameters
314
- .filter((parameter) => !parameter.required)
315
- .map((parameter) => parameter.name);
316
-
317
- const lines = [
318
- `# ${api.apiName}`,
319
- formatExample(api.path, api.method, api.parameters),
320
- ];
321
- if (optionalParams.length) {
322
- lines.push(`# optional params: ${optionalParams.join(", ")}`);
323
- }
324
-
325
- process.stdout.write(`${lines.join("\n")}\n`);
326
- }
327
-
328
451
  function summarizeNames(items, emptyLabel) {
329
452
  if (!items || !items.length) {
330
453
  return emptyLabel;
@@ -353,20 +476,96 @@ function summarizeNames(items, emptyLabel) {
353
476
  return `${names.slice(0, 6).join(", ")} ... (+${names.length - 6})`;
354
477
  }
355
478
 
356
- function runSearchApiCommand(args) {
479
+ function parseStructuredArgs(args) {
480
+ const params = {};
481
+
482
+ for (const arg of args) {
483
+ const equalIndex = arg.indexOf("=");
484
+ if (equalIndex <= 0) {
485
+ continue;
486
+ }
487
+ const key = arg.slice(0, equalIndex);
488
+ const value = arg.slice(equalIndex + 1);
489
+
490
+ if (Object.prototype.hasOwnProperty.call(params, key)) {
491
+ const existing = params[key];
492
+ params[key] = Array.isArray(existing) ? [...existing, value] : [existing, value];
493
+ } else {
494
+ params[key] = value;
495
+ }
496
+ }
497
+
498
+ return params;
499
+ }
500
+
501
+ function normalizeSearchCriteria(args) {
357
502
  const textMode = args.includes("--text");
358
- const query = args.filter((arg) => arg !== "--text").join(" ").trim();
359
- const { matches, error } = searchApis(query, 10);
503
+ const filteredArgs = args.filter((arg) => arg !== "--text");
504
+ const structuredArgs = filteredArgs.filter((arg) => arg.includes("="));
505
+ const positionalArgs = filteredArgs.filter((arg) => !arg.includes("="));
506
+ if (positionalArgs.length) {
507
+ exitWithError("ERROR: search-api only accepts structured inputs such as query=... and tool_ids=....");
508
+ }
509
+ const params = parseStructuredArgs(structuredArgs);
510
+
511
+ const queryInputs = ["query", "q"]
512
+ .filter((key) => params[key] !== undefined)
513
+ .flatMap((key) => (Array.isArray(params[key]) ? params[key] : [params[key]]));
514
+ if (queryInputs.length > 1) {
515
+ exitWithError("ERROR: search-api only accepts one query=... value. Use commas inside query=... for multiple keywords.");
516
+ }
517
+
518
+ const rawQueryValues = [];
519
+ for (const key of ["query", "q"]) {
520
+ if (params[key] !== undefined) {
521
+ const value = params[key];
522
+ rawQueryValues.push(...(Array.isArray(value) ? value : [value]));
523
+ }
524
+ }
525
+
526
+ const rawToolIdValues = [];
527
+ for (const key of ["tool_ids", "tool_id", "toolIds"]) {
528
+ if (params[key] !== undefined) {
529
+ const value = params[key];
530
+ rawToolIdValues.push(...(Array.isArray(value) ? value : [value]));
531
+ }
532
+ }
533
+
534
+ const query = rawQueryValues
535
+ .flatMap((value) => String(value || "").split(","))
536
+ .map((value) => value.trim())
537
+ .filter(Boolean);
538
+ const toolIds = rawToolIdValues
539
+ .flatMap((value) => String(value || "").split(","))
540
+ .map((value) => value.trim())
541
+ .filter(Boolean);
542
+
543
+ return {
544
+ textMode,
545
+ criteria: { query, toolIds },
546
+ };
547
+ }
548
+
549
+ function formatSearchLabel(criteria) {
550
+ const queryLabel = criteria.query.length ? `query='${criteria.query.join(" | ")}'` : null;
551
+ const toolIdLabel = criteria.toolIds.length ? `tool_ids=${criteria.toolIds.join(",")}` : null;
552
+ return [queryLabel, toolIdLabel].filter(Boolean).join(" ");
553
+ }
554
+
555
+ function runSearchApiCommand(args) {
556
+ const { textMode, criteria } = normalizeSearchCriteria(args);
557
+ const { matches, error } = searchApis(criteria, 10);
360
558
  if (error) {
361
559
  exitWithError(`ERROR: ${error}`);
362
560
  }
363
561
  if (!matches.length) {
364
- exitWithError(`ERROR: No endpoints matched '${query}'`);
562
+ exitWithError(`ERROR: No endpoints matched ${formatSearchLabel(criteria) || "the provided criteria"}`);
365
563
  }
366
564
 
367
565
  if (!textMode) {
368
566
  const payload = {
369
- query,
567
+ query: criteria.query.join(" "),
568
+ toolIds: criteria.toolIds,
370
569
  matches: matches.map((match) => ({
371
570
  apiName: match.apiName,
372
571
  path: match.path,
@@ -376,17 +575,22 @@ function runSearchApiCommand(args) {
376
575
  description: match.description,
377
576
  requestParams: match.parameters,
378
577
  responseFields: match.responseFields,
578
+ exampleCommand: formatExample(match.path, match.method, match.parameters),
379
579
  })),
380
580
  };
381
581
  process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
382
582
  return;
383
583
  }
384
584
 
385
- const lines = [`Matches for '${query}':`];
585
+ const lines = [`Matches for ${formatSearchLabel(criteria) || "the provided criteria"}:`];
386
586
  for (const match of matches) {
387
587
  lines.push(`- ${match.apiName} | ${match.path} | ${match.method} | ${match.groupPath.join(" / ")}`);
588
+ if (match.description || match.summary) {
589
+ lines.push(` desc: ${compactText(match.description || match.summary)}`);
590
+ }
388
591
  lines.push(` request params: ${summarizeNames(match.parameters, "none")}`);
389
592
  lines.push(` response fields: ${summarizeNames(match.responseFields, "none")}`);
593
+ lines.push(` example: ${formatExample(match.path, match.method, match.parameters)}`);
390
594
  }
391
595
 
392
596
  process.stdout.write(`${lines.join("\n")}\n`);
@@ -536,18 +740,28 @@ async function main(argv = process.argv.slice(2)) {
536
740
  return;
537
741
  }
538
742
 
539
- if (argv[0] === "list") {
540
- runListCommand(argv.slice(1));
743
+ if (argv[0] === "--version" || argv[0] === "-v" || argv[0] === "version") {
744
+ printVersion();
541
745
  return;
542
746
  }
543
747
 
544
- if (argv[0] === "schema") {
545
- runSchemaCommand(argv.slice(1));
748
+ if (argv[0] === "init") {
749
+ await runInitCommand(argv.slice(1));
750
+ return;
751
+ }
752
+
753
+ if (argv[0] === "config") {
754
+ runConfigCommand(argv.slice(1));
755
+ return;
756
+ }
757
+
758
+ if (argv[0] === "list") {
759
+ runListCommand(argv.slice(1));
546
760
  return;
547
761
  }
548
762
 
549
- if (argv[0] === "example") {
550
- runExampleCommand(argv.slice(1));
763
+ if (argv[0] === "schema" || argv[0] === "example") {
764
+ exitWithError("ERROR: The schema and example commands were removed. Use 'investoday-api search-api <query>' instead.");
551
765
  return;
552
766
  }
553
767
 
@@ -572,11 +786,13 @@ module.exports = {
572
786
  main,
573
787
  parseArgs,
574
788
  printHelp,
789
+ printVersion,
790
+ runConfigCommand,
791
+ runInitCommand,
575
792
  runSearchApiCommand,
576
- runExampleCommand,
577
793
  runListCommand,
578
- runSchemaCommand,
579
794
  selectRequestMethod,
795
+ verifyApiKey,
580
796
  };
581
797
 
582
798
  if (require.main === module) {
package/lib/config.js ADDED
@@ -0,0 +1,175 @@
1
+ const crypto = require("node:crypto");
2
+ const fs = require("node:fs");
3
+ const os = require("node:os");
4
+ const path = require("node:path");
5
+
6
+ const CONFIG_DIR_ENV = "INVESTODAY_API_CONFIG_DIR";
7
+ const API_KEY_ENV = "INVESTODAY_API_KEY";
8
+ const CREDENTIALS_FILE = "credentials.enc";
9
+ const KEY_FILE = ".encryption_key";
10
+ const CIPHER_ALGORITHM = "aes-256-gcm";
11
+
12
+ function getConfigDir(env = process.env) {
13
+ if (env[CONFIG_DIR_ENV]) {
14
+ return path.resolve(env[CONFIG_DIR_ENV]);
15
+ }
16
+
17
+ return path.join(os.homedir(), ".config", "investoday");
18
+ }
19
+
20
+ function getCredentialsPath(env = process.env) {
21
+ return path.join(getConfigDir(env), CREDENTIALS_FILE);
22
+ }
23
+
24
+ function getEncryptionKeyPath(env = process.env) {
25
+ return path.join(getConfigDir(env), KEY_FILE);
26
+ }
27
+
28
+ function ensureConfigDir(env = process.env) {
29
+ const dir = getConfigDir(env);
30
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
31
+ if (process.platform !== "win32") {
32
+ fs.chmodSync(dir, 0o700);
33
+ }
34
+ return dir;
35
+ }
36
+
37
+ function atomicWriteFile(filePath, contents, mode = 0o600) {
38
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
39
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
40
+ fs.writeFileSync(tempPath, contents, { mode });
41
+ if (process.platform !== "win32") {
42
+ fs.chmodSync(tempPath, mode);
43
+ }
44
+ fs.renameSync(tempPath, filePath);
45
+ }
46
+
47
+ function loadOrCreateEncryptionKey(env = process.env) {
48
+ ensureConfigDir(env);
49
+ const keyPath = getEncryptionKeyPath(env);
50
+ if (fs.existsSync(keyPath)) {
51
+ const encoded = fs.readFileSync(keyPath, "utf8").trim();
52
+ const key = Buffer.from(encoded, "base64");
53
+ if (key.length !== 32) {
54
+ throw new Error(`Invalid encryption key at ${keyPath}`);
55
+ }
56
+ return key;
57
+ }
58
+
59
+ const key = crypto.randomBytes(32);
60
+ atomicWriteFile(keyPath, key.toString("base64"));
61
+ return key;
62
+ }
63
+
64
+ function loadEncryptionKey(env = process.env) {
65
+ const keyPath = getEncryptionKeyPath(env);
66
+ if (!fs.existsSync(keyPath)) {
67
+ return null;
68
+ }
69
+
70
+ const encoded = fs.readFileSync(keyPath, "utf8").trim();
71
+ const key = Buffer.from(encoded, "base64");
72
+ if (key.length !== 32) {
73
+ return null;
74
+ }
75
+ return key;
76
+ }
77
+
78
+ function encryptJson(value, key) {
79
+ const iv = crypto.randomBytes(12);
80
+ const cipher = crypto.createCipheriv(CIPHER_ALGORITHM, key, iv);
81
+ const plaintext = Buffer.from(JSON.stringify(value), "utf8");
82
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
83
+ const tag = cipher.getAuthTag();
84
+
85
+ return {
86
+ version: 1,
87
+ algorithm: CIPHER_ALGORITHM,
88
+ iv: iv.toString("base64"),
89
+ tag: tag.toString("base64"),
90
+ data: encrypted.toString("base64"),
91
+ };
92
+ }
93
+
94
+ function decryptJson(payload, key) {
95
+ if (!payload || payload.algorithm !== CIPHER_ALGORITHM) {
96
+ throw new Error("Unsupported credentials format");
97
+ }
98
+
99
+ const decipher = crypto.createDecipheriv(
100
+ CIPHER_ALGORITHM,
101
+ key,
102
+ Buffer.from(payload.iv, "base64")
103
+ );
104
+ decipher.setAuthTag(Buffer.from(payload.tag, "base64"));
105
+ const decrypted = Buffer.concat([
106
+ decipher.update(Buffer.from(payload.data, "base64")),
107
+ decipher.final(),
108
+ ]);
109
+ return JSON.parse(decrypted.toString("utf8"));
110
+ }
111
+
112
+ function saveCredentials(apiKey, env = process.env) {
113
+ const trimmedKey = String(apiKey || "").trim();
114
+ if (!trimmedKey) {
115
+ throw new Error("API key cannot be empty");
116
+ }
117
+
118
+ const key = loadOrCreateEncryptionKey(env);
119
+ const payload = encryptJson({
120
+ apiKey: trimmedKey,
121
+ createdAt: new Date().toISOString(),
122
+ }, key);
123
+ atomicWriteFile(getCredentialsPath(env), `${JSON.stringify(payload, null, 2)}\n`);
124
+ }
125
+
126
+ function readCredentials(env = process.env) {
127
+ const credentialsPath = getCredentialsPath(env);
128
+ const key = loadEncryptionKey(env);
129
+ if (!key || !fs.existsSync(credentialsPath)) {
130
+ return null;
131
+ }
132
+
133
+ try {
134
+ const payload = JSON.parse(fs.readFileSync(credentialsPath, "utf8"));
135
+ return decryptJson(payload, key);
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ function removeCredentials(env = process.env) {
142
+ for (const filePath of [getCredentialsPath(env), getEncryptionKeyPath(env)]) {
143
+ if (fs.existsSync(filePath)) {
144
+ fs.rmSync(filePath, { force: true });
145
+ }
146
+ }
147
+ }
148
+
149
+ function resolveApiKey(env = process.env) {
150
+ const envKey = String(env[API_KEY_ENV] || "").trim();
151
+ if (envKey) {
152
+ return { apiKey: envKey, source: "compat" };
153
+ }
154
+
155
+ const credentials = readCredentials(env);
156
+ if (credentials && credentials.apiKey) {
157
+ return { apiKey: String(credentials.apiKey).trim(), source: "config" };
158
+ }
159
+
160
+ return { apiKey: "", source: "missing" };
161
+ }
162
+
163
+ module.exports = {
164
+ API_KEY_ENV,
165
+ CONFIG_DIR_ENV,
166
+ CREDENTIALS_FILE,
167
+ KEY_FILE,
168
+ getConfigDir,
169
+ getCredentialsPath,
170
+ getEncryptionKeyPath,
171
+ readCredentials,
172
+ removeCredentials,
173
+ resolveApiKey,
174
+ saveCredentials,
175
+ };
package/lib/metadata.js CHANGED
@@ -295,6 +295,34 @@ function scoreRecord(record, query) {
295
295
  return score;
296
296
  }
297
297
 
298
+ function matchesRecordQuery(record, query) {
299
+ return scoreRecord(record, query) > 0;
300
+ }
301
+
302
+ function normalizeSearchInput(input) {
303
+ const rawQueries = Array.isArray(input?.query)
304
+ ? input.query
305
+ : input?.query !== undefined && input?.query !== null && input?.query !== ""
306
+ ? [input.query]
307
+ : [];
308
+ const rawToolIds = Array.isArray(input?.toolIds)
309
+ ? input.toolIds
310
+ : input?.toolIds !== undefined && input?.toolIds !== null && input?.toolIds !== ""
311
+ ? [input.toolIds]
312
+ : [];
313
+
314
+ const queries = rawQueries
315
+ .flatMap((value) => String(value || "").split("\n"))
316
+ .map((value) => value.trim())
317
+ .filter(Boolean);
318
+ const toolIds = rawToolIds
319
+ .flatMap((value) => String(value || "").split(","))
320
+ .map((value) => value.trim())
321
+ .filter(Boolean);
322
+
323
+ return { queries, toolIds };
324
+ }
325
+
298
326
  function resolveApi(query) {
299
327
  const normalizedQuery = (query || "").trim();
300
328
  if (!normalizedQuery) {
@@ -333,17 +361,44 @@ function resolveApi(query) {
333
361
  return { matches: fuzzyMatches };
334
362
  }
335
363
 
336
- function searchApis(query, limit = 10) {
337
- const normalizedQuery = (query || "").trim();
338
- if (!normalizedQuery) {
339
- return { matches: [], error: "Please provide a search query." };
364
+ function searchApis(input, limit = 10) {
365
+ const { queries, toolIds } = normalizeSearchInput(input);
366
+ if (!queries.length && !toolIds.length) {
367
+ return { matches: [], error: "Please provide query=... and/or tool_ids=... ." };
340
368
  }
341
369
 
342
370
  const { records } = getMetadata();
343
- const scored = records
344
- .map((record) => ({ record, score: scoreRecord(record, normalizedQuery) }))
345
- .filter((item) => item.score > 0)
346
- .sort((left, right) => right.score - left.score || left.record.path.localeCompare(right.record.path))
371
+ let candidates = records;
372
+ const toolIdOrder = new Map(toolIds.map((toolId, index) => [toolId, index]));
373
+
374
+ if (toolIds.length) {
375
+ const toolIdSet = new Set(toolIds);
376
+ candidates = candidates.filter((record) => toolIdSet.has(record.toolId));
377
+ if (!queries.length) {
378
+ const matches = candidates
379
+ .sort((left, right) =>
380
+ (toolIdOrder.get(left.toolId) ?? Number.MAX_SAFE_INTEGER) -
381
+ (toolIdOrder.get(right.toolId) ?? Number.MAX_SAFE_INTEGER) ||
382
+ left.path.localeCompare(right.path)
383
+ )
384
+ .slice(0, limit);
385
+ return { matches };
386
+ }
387
+ }
388
+
389
+ const scored = candidates
390
+ .map((record) => ({
391
+ record,
392
+ matchedAllQueries: queries.every((query) => matchesRecordQuery(record, query)),
393
+ score: queries.reduce((sum, query) => sum + scoreRecord(record, query), 0),
394
+ }))
395
+ .filter((item) => item.matchedAllQueries && item.score > 0)
396
+ .sort((left, right) =>
397
+ right.score - left.score ||
398
+ (toolIdOrder.get(left.record.toolId) ?? Number.MAX_SAFE_INTEGER) -
399
+ (toolIdOrder.get(right.record.toolId) ?? Number.MAX_SAFE_INTEGER) ||
400
+ left.record.path.localeCompare(right.record.path)
401
+ )
347
402
  .slice(0, limit)
348
403
  .map((item) => item.record);
349
404
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@investoday/investoday-api",
3
- "version": "1.1.0",
3
+ "version": "1.8.0",
4
4
  "description": "CLI for accessing InvestToday China market financial data via investoday-api.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/investoday-data/investoday-api-skills",