@jackwener/opencli 1.5.3 → 1.5.5

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 (46) hide show
  1. package/.agents/skills/cross-project-adapter-migration/SKILL.md +3 -3
  2. package/README.md +213 -18
  3. package/dist/build-manifest.d.ts +2 -3
  4. package/dist/build-manifest.js +75 -170
  5. package/dist/build-manifest.test.js +113 -88
  6. package/dist/cli-manifest.json +1253 -1105
  7. package/dist/cli.js +14 -14
  8. package/dist/clis/antigravity/serve.js +2 -2
  9. package/dist/clis/sinafinance/rolling-news.d.ts +4 -0
  10. package/dist/clis/sinafinance/rolling-news.js +40 -0
  11. package/dist/clis/sinafinance/stock.d.ts +8 -0
  12. package/dist/clis/sinafinance/stock.js +117 -0
  13. package/dist/commanderAdapter.js +26 -3
  14. package/dist/daemon.js +19 -7
  15. package/dist/errors.d.ts +29 -1
  16. package/dist/errors.js +49 -11
  17. package/dist/external-clis.yaml +16 -0
  18. package/dist/external.js +3 -3
  19. package/dist/main.js +2 -1
  20. package/dist/serialization.js +6 -1
  21. package/dist/serialization.test.d.ts +1 -0
  22. package/dist/serialization.test.js +23 -0
  23. package/dist/tui.js +2 -1
  24. package/docs/adapters/browser/sinafinance.md +56 -6
  25. package/extension/dist/background.js +12 -6
  26. package/extension/manifest.json +2 -2
  27. package/extension/package-lock.json +2 -2
  28. package/extension/package.json +1 -1
  29. package/extension/src/background.ts +21 -6
  30. package/extension/src/protocol.ts +2 -1
  31. package/package.json +1 -1
  32. package/src/build-manifest.test.ts +117 -88
  33. package/src/build-manifest.ts +81 -180
  34. package/src/cli.ts +14 -14
  35. package/src/clis/antigravity/serve.ts +2 -2
  36. package/src/clis/sinafinance/rolling-news.ts +42 -0
  37. package/src/clis/sinafinance/stock.ts +127 -0
  38. package/src/commanderAdapter.ts +25 -2
  39. package/src/daemon.ts +21 -8
  40. package/src/errors.ts +71 -10
  41. package/src/external-clis.yaml +16 -0
  42. package/src/external.ts +3 -3
  43. package/src/main.ts +2 -1
  44. package/src/serialization.test.ts +26 -0
  45. package/src/serialization.ts +6 -1
  46. package/src/tui.ts +2 -1
@@ -14,6 +14,7 @@ import * as path from 'node:path';
14
14
  import { fileURLToPath, pathToFileURL } from 'node:url';
15
15
  import yaml from 'js-yaml';
16
16
  import { getErrorMessage } from './errors.js';
17
+ import { fullName, getRegistry, type CliCommand } from './registry.js';
17
18
 
18
19
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
20
  const CLIS_DIR = path.resolve(__dirname, 'clis');
@@ -52,116 +53,50 @@ import { type YamlCliDefinition, parseYamlArgs } from './yaml-schema.js';
52
53
 
53
54
  import { isRecord } from './utils.js';
54
55
 
55
-
56
- function extractBalancedBlock(
57
- source: string,
58
- startIndex: number,
59
- openChar: string,
60
- closeChar: string,
61
- ): string | null {
62
- let depth = 0;
63
- let quote: string | null = null;
64
- let escaped = false;
65
-
66
- for (let i = startIndex; i < source.length; i++) {
67
- const ch = source[i];
68
-
69
- if (quote) {
70
- if (escaped) {
71
- escaped = false;
72
- continue;
73
- }
74
- if (ch === '\\') {
75
- escaped = true;
76
- continue;
77
- }
78
- if (ch === quote) quote = null;
79
- continue;
80
- }
81
-
82
- if (ch === '"' || ch === '\'' || ch === '`') {
83
- quote = ch;
84
- continue;
85
- }
86
-
87
- if (ch === openChar) {
88
- depth++;
89
- } else if (ch === closeChar) {
90
- depth--;
91
- if (depth === 0) {
92
- return source.slice(startIndex + 1, i);
93
- }
94
- }
95
- }
96
-
97
- return null;
56
+ const CLI_MODULE_PATTERN = /\bcli\s*\(/;
57
+
58
+ function toManifestArgs(args: CliCommand['args']): ManifestEntry['args'] {
59
+ return args.map(arg => ({
60
+ name: arg.name,
61
+ type: arg.type ?? 'str',
62
+ default: arg.default,
63
+ required: !!arg.required,
64
+ positional: arg.positional || undefined,
65
+ help: arg.help ?? '',
66
+ choices: arg.choices,
67
+ }));
98
68
  }
99
69
 
100
- function extractTsArgsBlock(source: string): string | null {
101
- const argsMatch = source.match(/args\s*:/);
102
- if (!argsMatch || argsMatch.index === undefined) return null;
103
-
104
- const bracketIndex = source.indexOf('[', argsMatch.index);
105
- if (bracketIndex === -1) return null;
106
-
107
- return extractBalancedBlock(source, bracketIndex, '[', ']');
70
+ function toTsModulePath(filePath: string, site: string): string {
71
+ const baseName = path.basename(filePath, path.extname(filePath));
72
+ return `${site}/${baseName}.js`;
108
73
  }
109
74
 
110
- function parseInlineChoices(body: string): string[] | undefined {
111
- const choicesMatch = body.match(/choices\s*:\s*\[([^\]]*)\]/);
112
- if (!choicesMatch) return undefined;
113
-
114
- const values = choicesMatch[1]
115
- .split(',')
116
- .map(s => s.trim().replace(/^['"`]|['"`]$/g, ''))
117
- .filter(Boolean);
118
-
119
- return values.length > 0 ? values : undefined;
75
+ function isCliCommandValue(value: unknown, site: string): value is CliCommand {
76
+ return isRecord(value)
77
+ && typeof value.site === 'string'
78
+ && value.site === site
79
+ && typeof value.name === 'string'
80
+ && Array.isArray(value.args);
120
81
  }
121
82
 
122
- export function parseTsArgsBlock(argsBlock: string): ManifestEntry['args'] {
123
- const args: ManifestEntry['args'] = [];
124
- let cursor = 0;
125
-
126
- while (cursor < argsBlock.length) {
127
- const nameMatch = argsBlock.slice(cursor).match(/\{\s*name\s*:\s*['"`]([^'"`]+)['"`]/);
128
- if (!nameMatch || nameMatch.index === undefined) break;
129
-
130
- const objectStart = cursor + nameMatch.index;
131
- const body = extractBalancedBlock(argsBlock, objectStart, '{', '}');
132
- if (body == null) break;
133
-
134
- const typeMatch = body.match(/type\s*:\s*['"`](\w+)['"`]/);
135
- const defaultMatch = body.match(/default\s*:\s*([^,}]+)/);
136
- const requiredMatch = body.match(/required\s*:\s*(true|false)/);
137
- const helpMatch = body.match(/help\s*:\s*['"`]([^'"`]*)['"`]/);
138
- const positionalMatch = body.match(/positional\s*:\s*(true|false)/);
139
-
140
- let defaultVal: unknown = undefined;
141
- if (defaultMatch) {
142
- const raw = defaultMatch[1].trim();
143
- if (raw === 'true') defaultVal = true;
144
- else if (raw === 'false') defaultVal = false;
145
- else if (/^\d+$/.test(raw)) defaultVal = parseInt(raw, 10);
146
- else if (/^\d+\.\d+$/.test(raw)) defaultVal = parseFloat(raw);
147
- else defaultVal = raw.replace(/^['"`]|['"`]$/g, '');
148
- }
149
-
150
- args.push({
151
- name: nameMatch[1],
152
- type: typeMatch?.[1] ?? 'str',
153
- default: defaultVal,
154
- required: requiredMatch?.[1] === 'true',
155
- positional: positionalMatch?.[1] === 'true' || undefined,
156
- help: helpMatch?.[1] ?? '',
157
- choices: parseInlineChoices(body),
158
- });
159
-
160
- cursor = objectStart + body.length;
161
- if (cursor <= objectStart) break; // safety: prevent infinite loop
162
- }
163
-
164
- return args;
83
+ function toManifestEntry(cmd: CliCommand, modulePath: string): ManifestEntry {
84
+ return {
85
+ site: cmd.site,
86
+ name: cmd.name,
87
+ description: cmd.description ?? '',
88
+ domain: cmd.domain,
89
+ strategy: (cmd.strategy ?? 'public').toString().toLowerCase(),
90
+ browser: cmd.browser ?? true,
91
+ args: toManifestArgs(cmd.args),
92
+ columns: cmd.columns,
93
+ timeout: cmd.timeoutSeconds,
94
+ deprecated: cmd.deprecated,
95
+ replacedBy: cmd.replacedBy,
96
+ type: 'ts',
97
+ modulePath,
98
+ navigateBefore: cmd.navigateBefore,
99
+ };
165
100
  }
166
101
 
167
102
  function scanYaml(filePath: string, site: string): ManifestEntry | null {
@@ -199,83 +134,49 @@ function scanYaml(filePath: string, site: string): ManifestEntry | null {
199
134
  }
200
135
  }
201
136
 
202
- export function scanTs(filePath: string, site: string): ManifestEntry | null {
203
- // TS adapters self-register via cli() at import time.
204
- // We statically parse the source to extract metadata for the manifest stub.
205
- const baseName = path.basename(filePath, path.extname(filePath));
206
- const relativePath = `${site}/${baseName}.js`;
207
-
137
+ export async function loadTsManifestEntries(
138
+ filePath: string,
139
+ site: string,
140
+ importer: (moduleHref: string) => Promise<unknown> = moduleHref => import(moduleHref),
141
+ ): Promise<ManifestEntry[]> {
208
142
  try {
209
143
  const src = fs.readFileSync(filePath, 'utf-8');
210
144
 
211
145
  // Helper/test modules should not appear as CLI commands in the manifest.
212
- if (!/\bcli\s*\(/.test(src)) return null;
213
-
214
- const entry: ManifestEntry = {
215
- site,
216
- name: baseName,
217
- description: '',
218
- strategy: 'cookie',
219
- browser: true,
220
- args: [],
221
- type: 'ts',
222
- modulePath: relativePath,
223
- };
224
-
225
- // Extract description
226
- const descMatch = src.match(/description\s*:\s*['"`]([^'"`]*)['"`]/);
227
- if (descMatch) entry.description = descMatch[1];
228
-
229
- // Extract domain
230
- const domainMatch = src.match(/domain\s*:\s*['"`]([^'"`]*)['"`]/);
231
- if (domainMatch) entry.domain = domainMatch[1];
232
-
233
- // Extract strategy
234
- const stratMatch = src.match(/strategy\s*:\s*Strategy\.(\w+)/);
235
- if (stratMatch) entry.strategy = stratMatch[1].toLowerCase();
236
-
237
- // Extract browser: false (some adapters bypass browser entirely)
238
- const browserMatch = src.match(/browser\s*:\s*(true|false)/);
239
- if (browserMatch) entry.browser = browserMatch[1] === 'true';
240
- else entry.browser = entry.strategy !== 'public';
241
-
242
- // Extract columns
243
- const colMatch = src.match(/columns\s*:\s*\[([^\]]*)\]/);
244
- if (colMatch) {
245
- entry.columns = colMatch[1].split(',').map(s => s.trim().replace(/^['"`]|['"`]$/g, '')).filter(Boolean);
246
- }
247
-
248
- // Extract args array items: { name: '...', ... }
249
- const argsBlock = extractTsArgsBlock(src);
250
- if (argsBlock) {
251
- entry.args = parseTsArgsBlock(argsBlock);
252
- }
253
-
254
- // Extract navigateBefore: false / true / 'https://...'
255
- const navBoolMatch = src.match(/navigateBefore\s*:\s*(true|false)/);
256
- if (navBoolMatch) {
257
- entry.navigateBefore = navBoolMatch[1] === 'true';
258
- } else {
259
- const navStringMatch = src.match(/navigateBefore\s*:\s*['"`]([^'"`]+)['"`]/);
260
- if (navStringMatch) entry.navigateBefore = navStringMatch[1];
261
- }
262
-
263
- const deprecatedBoolMatch = src.match(/deprecated\s*:\s*(true|false)/);
264
- if (deprecatedBoolMatch) {
265
- entry.deprecated = deprecatedBoolMatch[1] === 'true';
266
- } else {
267
- const deprecatedStringMatch = src.match(/deprecated\s*:\s*['"`]([^'"`]+)['"`]/);
268
- if (deprecatedStringMatch) entry.deprecated = deprecatedStringMatch[1];
269
- }
270
-
271
- const replacedByMatch = src.match(/replacedBy\s*:\s*['"`]([^'"`]+)['"`]/);
272
- if (replacedByMatch) entry.replacedBy = replacedByMatch[1];
273
-
274
- return entry;
146
+ if (!CLI_MODULE_PATTERN.test(src)) return [];
147
+
148
+ const modulePath = toTsModulePath(filePath, site);
149
+ const registry = getRegistry();
150
+ const before = new Map(registry.entries());
151
+ const mod = await importer(pathToFileURL(filePath).href);
152
+
153
+ const exportedCommands = Object.values(isRecord(mod) ? mod : {})
154
+ .filter(value => isCliCommandValue(value, site));
155
+
156
+ const runtimeCommands = exportedCommands.length > 0
157
+ ? exportedCommands
158
+ : [...registry.entries()]
159
+ .filter(([key, cmd]) => {
160
+ if (cmd.site !== site) return false;
161
+ const previous = before.get(key);
162
+ return !previous || previous !== cmd;
163
+ })
164
+ .map(([, cmd]) => cmd);
165
+
166
+ const seen = new Set<string>();
167
+ return runtimeCommands
168
+ .filter((cmd) => {
169
+ const key = fullName(cmd);
170
+ if (seen.has(key)) return false;
171
+ seen.add(key);
172
+ return true;
173
+ })
174
+ .sort((a, b) => a.name.localeCompare(b.name))
175
+ .map(cmd => toManifestEntry(cmd, modulePath));
275
176
  } catch (err) {
276
177
  // If parsing fails, log a warning (matching scanYaml behaviour) and skip the entry.
277
178
  process.stderr.write(`Warning: failed to scan ${filePath}: ${getErrorMessage(err)}\n`);
278
- return null;
179
+ return [];
279
180
  }
280
181
  }
281
182
 
@@ -288,7 +189,7 @@ export function shouldReplaceManifestEntry(current: ManifestEntry, next: Manifes
288
189
  return current.type === 'yaml' && next.type === 'ts';
289
190
  }
290
191
 
291
- export function buildManifest(): ManifestEntry[] {
192
+ export async function buildManifest(): Promise<ManifestEntry[]> {
292
193
  const manifest = new Map<string, ManifestEntry>();
293
194
 
294
195
  if (fs.existsSync(CLIS_DIR)) {
@@ -313,8 +214,8 @@ export function buildManifest(): ManifestEntry[] {
313
214
  (file.endsWith('.ts') && !file.endsWith('.d.ts') && !file.endsWith('.test.ts') && file !== 'index.ts') ||
314
215
  (file.endsWith('.js') && !file.endsWith('.d.js') && !file.endsWith('.test.js') && file !== 'index.js')
315
216
  ) {
316
- const entry = scanTs(filePath, site);
317
- if (entry) {
217
+ const entries = await loadTsManifestEntries(filePath, site);
218
+ for (const entry of entries) {
318
219
  const key = `${entry.site}/${entry.name}`;
319
220
  const existing = manifest.get(key);
320
221
  if (!existing || shouldReplaceManifestEntry(existing, entry)) {
@@ -332,8 +233,8 @@ export function buildManifest(): ManifestEntry[] {
332
233
  return [...manifest.values()];
333
234
  }
334
235
 
335
- function main(): void {
336
- const manifest = buildManifest();
236
+ async function main(): Promise<void> {
237
+ const manifest = await buildManifest();
337
238
  fs.mkdirSync(path.dirname(OUTPUT), { recursive: true });
338
239
  fs.writeFileSync(OUTPUT, JSON.stringify(manifest, null, 2));
339
240
 
@@ -367,5 +268,5 @@ function main(): void {
367
268
 
368
269
  const entrypoint = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null;
369
270
  if (entrypoint === import.meta.url) {
370
- main();
271
+ void main();
371
272
  }
package/src/cli.ts CHANGED
@@ -15,7 +15,7 @@ import { PKG_VERSION } from './version.js';
15
15
  import { printCompletionScript } from './completion.js';
16
16
  import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js';
17
17
  import { registerAllCommands } from './commanderAdapter.js';
18
- import { getErrorMessage } from './errors.js';
18
+ import { EXIT_CODES, getErrorMessage } from './errors.js';
19
19
 
20
20
  export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
21
21
  const program = new Command();
@@ -120,7 +120,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
120
120
  const { verifyClis, renderVerifyReport } = await import('./verify.js');
121
121
  const r = await verifyClis({ builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, target, smoke: opts.smoke });
122
122
  console.log(renderVerifyReport(r));
123
- process.exitCode = r.ok ? 0 : 1;
123
+ process.exitCode = r.ok ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR;
124
124
  });
125
125
 
126
126
  // ── Built-in: explore / synthesize / generate / cascade ───────────────────
@@ -180,7 +180,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
180
180
  workspace,
181
181
  });
182
182
  console.log(renderGenerateSummary(r));
183
- process.exitCode = r.ok ? 0 : 1;
183
+ process.exitCode = r.ok ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR;
184
184
  });
185
185
 
186
186
  // ── Built-in: record ─────────────────────────────────────────────────────
@@ -204,7 +204,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
204
204
  timeoutMs: parseInt(opts.timeout, 10),
205
205
  });
206
206
  console.log(renderRecordSummary(result));
207
- process.exitCode = result.candidateCount > 0 ? 0 : 1;
207
+ process.exitCode = result.candidateCount > 0 ? EXIT_CODES.SUCCESS : EXIT_CODES.EMPTY_RESULT;
208
208
  });
209
209
 
210
210
  program
@@ -272,7 +272,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
272
272
  }
273
273
  } catch (err) {
274
274
  console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
275
- process.exitCode = 1;
275
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
276
276
  }
277
277
  });
278
278
 
@@ -287,7 +287,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
287
287
  console.log(chalk.green(`✅ Plugin "${name}" uninstalled.`));
288
288
  } catch (err) {
289
289
  console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
290
- process.exitCode = 1;
290
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
291
291
  }
292
292
  });
293
293
 
@@ -299,12 +299,12 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
299
299
  .action(async (name: string | undefined, opts: { all?: boolean }) => {
300
300
  if (!name && !opts.all) {
301
301
  console.error(chalk.red('Error: Please specify a plugin name or use the --all flag.'));
302
- process.exitCode = 1;
302
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
303
303
  return;
304
304
  }
305
305
  if (name && opts.all) {
306
306
  console.error(chalk.red('Error: Cannot specify both a plugin name and --all.'));
307
- process.exitCode = 1;
307
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
308
308
  return;
309
309
  }
310
310
 
@@ -335,7 +335,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
335
335
  console.log();
336
336
  if (hasErrors) {
337
337
  console.error(chalk.red('Completed with some errors.'));
338
- process.exitCode = 1;
338
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
339
339
  } else {
340
340
  console.log(chalk.green('✅ All plugins updated successfully.'));
341
341
  }
@@ -348,7 +348,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
348
348
  console.log(chalk.green(`✅ Plugin "${name}" updated successfully.`));
349
349
  } catch (err) {
350
350
  console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
351
- process.exitCode = 1;
351
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
352
352
  }
353
353
  });
354
354
 
@@ -438,7 +438,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
438
438
  console.log(chalk.dim(` opencli ${name} hello`));
439
439
  } catch (err) {
440
440
  console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
441
- process.exitCode = 1;
441
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
442
442
  }
443
443
  });
444
444
 
@@ -454,7 +454,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
454
454
  const ext = externalClis.find(e => e.name === name);
455
455
  if (!ext) {
456
456
  console.error(chalk.red(`External CLI '${name}' not found in registry.`));
457
- process.exitCode = 1;
457
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
458
458
  return;
459
459
  }
460
460
  installExternalCli(ext);
@@ -480,7 +480,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
480
480
  executeExternalCli(name, args, externalClis);
481
481
  } catch (err) {
482
482
  console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
483
- process.exitCode = 1;
483
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
484
484
  }
485
485
  }
486
486
 
@@ -525,7 +525,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
525
525
  console.error(chalk.dim(` Tip: '${binary}' exists on your PATH. Use 'opencli register ${binary}' to add it as an external CLI.`));
526
526
  }
527
527
  program.outputHelp();
528
- process.exitCode = 1;
528
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
529
529
  });
530
530
 
531
531
  program.parse();
@@ -13,7 +13,7 @@
13
13
  import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
14
14
  import { CDPBridge } from '../../browser/cdp.js';
15
15
  import type { IPage } from '../../types.js';
16
- import { getErrorMessage } from '../../errors.js';
16
+ import { EXIT_CODES, getErrorMessage } from '../../errors.js';
17
17
 
18
18
  // ─── Types ───────────────────────────────────────────────────────────
19
19
 
@@ -594,7 +594,7 @@ export async function startServe(opts: { port?: number } = {}): Promise<void> {
594
594
  console.error('\n[serve] Shutting down...');
595
595
  cdp?.close().catch(() => {});
596
596
  server.close();
597
- process.exit(0);
597
+ process.exit(EXIT_CODES.SUCCESS);
598
598
  };
599
599
  process.on('SIGTERM', shutdown);
600
600
  process.on('SIGINT', shutdown);
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Sinafinance rolling news feed
3
+ */
4
+
5
+ import { cli, Strategy } from '../../registry.js';
6
+
7
+ cli({
8
+ site: 'sinafinance',
9
+ name: 'rolling-news',
10
+ description: '新浪财经滚动新闻',
11
+ domain: 'finance.sina.com.cn/roll',
12
+ strategy: Strategy.COOKIE,
13
+ args: [],
14
+ columns: ['column', 'title', 'date', 'url'],
15
+ func: async (page, _args) => {
16
+ await page.goto(`https://finance.sina.com.cn/roll/#pageid=384&lid=2519`);
17
+ await page.wait({ selector: '.d_list_txt li', timeout: 10000 });
18
+
19
+ const payload = await page.evaluate(`
20
+ (() => {
21
+ const cleanText = (value) => (value || '').replace(/\\s+/g, ' ').trim();
22
+ const results = [];
23
+ document.querySelectorAll('.d_list_txt li').forEach(el => {
24
+ const titleEl = el.querySelector('.c_tit a');
25
+ const columnEl = el.querySelector('.c_chl');
26
+ const dateEl = el.querySelector('.c_time');
27
+ const url = titleEl?.getAttribute('href') || '';
28
+ if (!url) return;
29
+ results.push({
30
+ title: cleanText(titleEl?.textContent || ''),
31
+ column: cleanText(columnEl?.textContent || ''),
32
+ date: cleanText(dateEl?.textContent || ''),
33
+ url: url,
34
+ });
35
+ });
36
+ return results;
37
+ })()
38
+ `);
39
+ if (!Array.isArray(payload)) return [];
40
+ return payload;
41
+ },
42
+ });
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Sinafinance stock quote — A股 / 港股 / 美股
3
+ *
4
+ * Uses two public Sina APIs (no browser required):
5
+ * suggest3.sinajs.cn — symbol search
6
+ * hq.sinajs.cn — real-time quote
7
+ */
8
+
9
+ import { cli, Strategy } from '../../registry.js';
10
+ import { CliError } from '../../errors.js';
11
+
12
+ const MARKET_CN = '11';
13
+ const MARKET_HK = '31';
14
+ const MARKET_US = '41';
15
+
16
+ async function fetchGBK(url: string): Promise<string> {
17
+ const res = await fetch(url, { headers: { Referer: 'https://finance.sina.com.cn' } });
18
+ if (!res.ok) throw new CliError('FETCH_ERROR', `Sina API HTTP ${res.status}`, 'Check your network');
19
+ const buf = await res.arrayBuffer();
20
+ return new TextDecoder('gbk').decode(buf);
21
+ }
22
+
23
+ interface SuggestEntry { name: string; market: string; symbol: string; }
24
+
25
+ function parseSuggest(raw: string, markets: string[]): SuggestEntry[] {
26
+ const m = raw.match(/suggestvalue="(.*)"/s);
27
+ if (!m) return [];
28
+ return m[1].split(';').filter(Boolean).map(s => {
29
+ const p = s.split(',');
30
+ return { name: p[4] || p[0] || '', market: p[1] || '', symbol: p[3] || '' };
31
+ }).filter(e => markets.includes(e.market));
32
+ }
33
+
34
+ function hqSymbol(e: SuggestEntry): string {
35
+ if (e.market === MARKET_HK) return `hk${e.symbol}`;
36
+ if (e.market === MARKET_US) return `gb_${e.symbol}`;
37
+ return e.symbol; // A股: already "sh600519" / "sz300XXX"
38
+ }
39
+
40
+ function parseHq(raw: string, sym: string): string[] {
41
+ const escaped = sym.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
42
+ const m = raw.match(new RegExp(`hq_str_${escaped}="([^"]*)"`));
43
+ return m ? m[1].split(',') : [];
44
+ }
45
+
46
+ function fmtMktCap(val: string): string {
47
+ const n = parseFloat(val);
48
+ if (!n) return '';
49
+ if (n >= 1e12) return (n / 1e12).toFixed(2) + 'T';
50
+ if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
51
+ if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
52
+ return String(n);
53
+ }
54
+
55
+ cli({
56
+ site: 'sinafinance',
57
+ name: 'stock',
58
+ description: '新浪财经行情(A股/港股/美股)',
59
+ domain: 'suggest3.sinajs.cn,hq.sinajs.cn',
60
+ strategy: Strategy.PUBLIC,
61
+ browser: false,
62
+ args: [
63
+ { name: 'key', type: 'string', required: true, positional: true, help: 'Stock name or code (e.g. 贵州茅台, 腾讯控股, AAPL)' },
64
+ { name: 'market', type: 'string', default: 'auto', help: 'Market: cn, hk, us, auto (default: auto searches cn → hk → us)' },
65
+ ],
66
+ columns: ['Symbol', 'Name', 'Price', 'Change', 'ChangePercent', 'Open', 'High', 'Low', 'Volume', 'MarketCap'],
67
+ func: async (_page, args) => {
68
+ const key = String(args.key);
69
+ const market = String(args.market);
70
+
71
+ const marketMap: Record<string, string[]> = {
72
+ cn: [MARKET_CN], hk: [MARKET_HK], us: [MARKET_US],
73
+ auto: [MARKET_CN, MARKET_HK, MARKET_US],
74
+ };
75
+ const targetMarkets = marketMap[market];
76
+ if (!targetMarkets) {
77
+ throw new CliError('INPUT_ERROR', `Invalid market: "${market}"`, 'Expected cn, hk, us, or auto');
78
+ }
79
+
80
+ // 1. Search symbol — only request the markets we care about
81
+ const suggestRaw = await fetchGBK(
82
+ `https://suggest3.sinajs.cn/suggest/type=${targetMarkets.join(',')}&key=${encodeURIComponent(key)}`
83
+ );
84
+ const entries = parseSuggest(suggestRaw, targetMarkets);
85
+ if (!entries.length) {
86
+ throw new CliError('NOT_FOUND', `No stock found for "${key}"`, 'Try a different name, code, or --market');
87
+ }
88
+
89
+ // Pick best match: score by name similarity, tiebreak by market priority
90
+ const needle = key.toLowerCase();
91
+ const score = (e: SuggestEntry): number => {
92
+ const n = e.name.toLowerCase();
93
+ if (n === needle) return 1;
94
+ if (n.includes(needle)) return needle.length / n.length;
95
+ return 0;
96
+ };
97
+ const best = entries.sort((a, b) => {
98
+ const d = score(b) - score(a);
99
+ return d !== 0 ? d : targetMarkets.indexOf(a.market) - targetMarkets.indexOf(b.market);
100
+ })[0];
101
+
102
+ // 2. Fetch quote
103
+ const sym = hqSymbol(best);
104
+ const hqRaw = await fetchGBK(`https://hq.sinajs.cn/list=${sym}`);
105
+ const f = parseHq(hqRaw, sym);
106
+
107
+ if (f.length < 2 || !f[0]) {
108
+ throw new CliError('NOT_FOUND', `No quote data for "${key}"`, 'Market may be closed or data unavailable');
109
+ }
110
+
111
+ if (best.market === MARKET_CN) {
112
+ const price = parseFloat(f[3]);
113
+ const prev = parseFloat(f[2]);
114
+ const chg = (price - prev).toFixed(2);
115
+ const chgPct = ((price - prev) / prev * 100).toFixed(2) + '%';
116
+ return [{ Symbol: sym.toUpperCase(), Name: f[0], Price: f[3], Change: chg, ChangePercent: chgPct, Open: f[1], High: f[4], Low: f[5], Volume: f[8], MarketCap: '' }];
117
+ }
118
+
119
+ if (best.market === MARKET_HK) {
120
+ // [2]=price [4]=high [5]=low [6]=open [7]=change [8]=change% [11]=volume
121
+ return [{ Symbol: best.symbol, Name: f[1], Price: f[2], Change: f[7], ChangePercent: f[8] + '%', Open: f[6], High: f[4], Low: f[5], Volume: f[11], MarketCap: '' }];
122
+ }
123
+
124
+ // MARKET_US: [1]=price [2]=change% [4]=change [6]=open [7]=today_low [8]=52wH [9]=52wL [10]=volume [12]=mktcap
125
+ return [{ Symbol: best.symbol.toUpperCase(), Name: f[0], Price: f[1], Change: f[4], ChangePercent: f[2] + '%', Open: f[6], High: f[8], Low: f[9], Volume: f[10], MarketCap: fmtMktCap(f[12]) }];
126
+ },
127
+ });