@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 +15 -13
- package/lib/call-api.js +297 -81
- package/lib/config.js +175 -0
- package/lib/metadata.js +63 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,10 +10,10 @@ npm install -g @investoday/investoday-api
|
|
|
10
10
|
|
|
11
11
|
## API key
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
Configure the API key once:
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
|
-
|
|
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
|
|
30
|
-
investoday-api
|
|
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
|
|
42
|
-
investoday-api
|
|
43
|
-
investoday-api search-api
|
|
44
|
-
investoday-api search-api
|
|
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
|
-
-
|
|
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
|
|
55
|
-
- `search-api` defaults to JSON output
|
|
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
|
|
13
|
-
if (
|
|
14
|
-
return
|
|
23
|
+
const { apiKey } = resolveApiKey();
|
|
24
|
+
if (apiKey) {
|
|
25
|
+
return apiKey;
|
|
15
26
|
}
|
|
16
27
|
|
|
17
|
-
exitWithError(
|
|
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
|
|
27
|
-
" investoday-api
|
|
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
|
-
"
|
|
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
|
|
39
|
-
" investoday-api
|
|
40
|
-
" investoday-api search-api
|
|
41
|
-
" investoday-api search-api
|
|
42
|
-
" investoday-api
|
|
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
|
|
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
|
|
359
|
-
const
|
|
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
|
|
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
|
|
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] === "
|
|
540
|
-
|
|
743
|
+
if (argv[0] === "--version" || argv[0] === "-v" || argv[0] === "version") {
|
|
744
|
+
printVersion();
|
|
541
745
|
return;
|
|
542
746
|
}
|
|
543
747
|
|
|
544
|
-
if (argv[0] === "
|
|
545
|
-
|
|
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
|
-
|
|
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(
|
|
337
|
-
const
|
|
338
|
-
if (!
|
|
339
|
-
return { matches: [], error: "Please provide
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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.
|
|
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",
|