From d4dd923f04430f50b8dc30f38dc95e4f6f770bd7 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sat, 28 Mar 2026 21:54:23 +0800 Subject: [PATCH 1/3] feat(sinafinance): rewrite stock as public API adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace browser-based DOM scraping with direct Sina public APIs: suggest3.sinajs.cn — symbol search (GBK, no auth) hq.sinajs.cn — real-time quote (GBK, no auth) Strategy.PUBLIC, browser: false — no Chrome or login required. Supports A股 (sh/sz), 港股 (hk prefix), 美股 (gb_ prefix). US MarketCap parsed from hq field [12]; formatted as T/B/M. --- docs/adapters/browser/sinafinance.md | 14 +- src/clis/sinafinance/stock.ts | 278 ++++++++++----------------- 2 files changed, 111 insertions(+), 181 deletions(-) diff --git a/docs/adapters/browser/sinafinance.md b/docs/adapters/browser/sinafinance.md index 488b2d89..2ca19cbb 100644 --- a/docs/adapters/browser/sinafinance.md +++ b/docs/adapters/browser/sinafinance.md @@ -8,7 +8,7 @@ |---------|-------------|------| | `opencli sinafinance news` | 新浪财经 7×24 小时实时快讯 | 🌐 Public | | `opencli sinafinance rolling-news` | 新浪财经滚动新闻 | 🔐 Browser | -| `opencli sinafinance stock` | 新浪财经行情(A股/港股/美股) | 🔐 Browser | +| `opencli sinafinance stock` | 新浪财经行情(A股/港股/美股) | 🌐 Public | ## Usage Examples @@ -69,17 +69,17 @@ opencli sinafinance stock 贵州茅台 -f json | Option | Description | |--------|-------------| -| `--key` | Stock name or code to search (required) | | `--market` | Market: `cn`, `hk`, `us`, `auto` (default: auto). When `auto`, searches in cn, hk, us order | ## Prerequisites -- `news`: No browser required — uses public API -- `rolling-news` & `stock`: Chrome running and **logged into** `finance.sina.com.cn` -- For `rolling-news` & `stock`: [Browser Bridge extension](/guide/browser-bridge) installed +- `news` & `stock`: No browser required — uses public API +- `rolling-news`: Chrome running and **logged into** `finance.sina.com.cn` +- For `rolling-news`: [Browser Bridge extension](/guide/browser-bridge) installed ## Notes -- `news` command uses a public API and does not require browser or login -- `stock` command supports Chinese stock names and codes, and automatically detects the market +- `news` and `stock` use public APIs — no browser or login needed +- `stock` supports Chinese names, Chinese codes, and ticker symbols; auto-detects market - Market priority for auto-detection: cn (A股) → hk (港股) → us (美股) +- US stock `High`/`Low` columns show 52-week range; A股/港股 show today's range diff --git a/src/clis/sinafinance/stock.ts b/src/clis/sinafinance/stock.ts index 38fc95f7..a408e527 100644 --- a/src/clis/sinafinance/stock.ts +++ b/src/clis/sinafinance/stock.ts @@ -1,6 +1,9 @@ /** - * Sinafinance quote stock - * A股 / 港股 / 美股 + * Sinafinance stock quote — A股 / 港股 / 美股 + * + * Uses two public Sina APIs (no browser required): + * suggest3.sinajs.cn — symbol search + * hq.sinajs.cn — real-time quote */ import { cli, Strategy } from '../../registry.js'; @@ -10,187 +13,114 @@ const MARKET_CN = '11'; const MARKET_HK = '31'; const MARKET_US = '41'; +async function fetchGBK(url: string): Promise { + const res = await fetch(url, { headers: { Referer: 'https://finance.sina.com.cn' } }); + if (!res.ok) throw new CliError('FETCH_ERROR', `Sina API HTTP ${res.status}`, 'Check your network'); + const buf = await res.arrayBuffer(); + return new TextDecoder('gbk').decode(buf); +} + +interface SuggestEntry { name: string; market: string; symbol: string; } + +function parseSuggest(raw: string, markets: string[]): SuggestEntry[] { + const m = raw.match(/suggestvalue="(.*)"/s); + if (!m) return []; + return m[1].split(';').filter(Boolean).map(s => { + const p = s.split(','); + return { name: p[4] || p[0] || '', market: p[1] || '', symbol: p[3] || '' }; + }).filter(e => markets.includes(e.market)); +} + +function hqSymbol(e: SuggestEntry): string { + if (e.market === MARKET_HK) return `hk${e.symbol}`; + if (e.market === MARKET_US) return `gb_${e.symbol}`; + return e.symbol; // A股: already "sh600519" / "sz300XXX" +} + +function parseHq(raw: string, sym: string): string[] { + const m = raw.match(new RegExp(`hq_str_${sym}="([^"]*)"`)); + return m ? m[1].split(',') : []; +} + +function fmtMktCap(val: string): string { + const n = parseFloat(val); + if (!n) return ''; + if (n >= 1e12) return (n / 1e12).toFixed(2) + 'T'; + if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B'; + if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M'; + return String(n); +} + cli({ site: 'sinafinance', name: 'stock', - description: '新浪财经行情', - domain: 'finance.sina.cn', - strategy: Strategy.COOKIE, + description: '新浪财经行情(A股/港股/美股)', + domain: 'suggest3.sinajs.cn,hq.sinajs.cn', + strategy: Strategy.PUBLIC, + browser: false, args: [ - { name: 'key', type: 'string', required: true, positional: true, help: 'stock name or code to search' }, - { name: 'market', type: 'string', default: 'auto', help: 'Market: cn, hk, us, auto(default). auto searches cn → hk → us in order' }, + { name: 'key', type: 'string', required: true, positional: true, help: 'Stock name or code (e.g. 贵州茅台, 腾讯控股, AAPL)' }, + { name: 'market', type: 'string', default: 'auto', help: 'Market: cn, hk, us, auto (default: auto searches cn → hk → us)' }, ], columns: ['Symbol', 'Name', 'Price', 'Change', 'ChangePercent', 'Open', 'High', 'Low', 'Volume', 'MarketCap'], - func: async (page, args) => { - await page.goto('https://finance.sina.com.cn/stock/'); - await page.wait({ selector: '#suggest01_input', timeout: 10000 }); - - // Use JSON.stringify to safely pass user input into the browser context - const searchKey = JSON.stringify(String(args.key)); - const searchMarket = JSON.stringify(String(args.market)); - - const suggestRes = await page.evaluate(` - (async() => { - const searchKey = ${searchKey}; - const searchMarket = ${searchMarket}; - const sleep = (ms) => new Promise(r => setTimeout(r, ms)); - const waitForElement = async (selector, timeout = 5000) => { - const start = Date.now(); - while (Date.now() - start < timeout) { - const el = document.querySelector(selector); - if (el) return el; - await sleep(100); - } - return null; - }; - const inputEl = document.getElementById('suggest01_input'); - if (!inputEl) return null; - inputEl.focus(); - inputEl.value = searchKey; - inputEl.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: '0', code: 'Digit0' })); - inputEl.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: '0', code: 'Digit0' })); - inputEl.dispatchEvent(new Event('change', { bubbles: true })); - await sleep(500); - const suggestDOM = await waitForElement('#fcSuggest_140418'); - if (!suggestDOM) return null; - const table = suggestDOM.previousElementSibling; - if (!table || table.tagName !== 'TABLE') return null; - const marketMap = { cn: '${MARKET_CN}', hk: '${MARKET_HK}', us: '${MARKET_US}' }; - const targetMarket = marketMap[searchMarket] || 'auto'; - const results = []; - const matchedRes = []; - const rows = table.querySelectorAll('tr'); - for (const tr of rows) { - const id = tr.id; - if (!id) continue; - const idParts = id.split(','); - const stockName = idParts[0] || ''; - const market = idParts[1] || ''; - const symbol = idParts[3] || ''; - if (!['${MARKET_CN}', '${MARKET_HK}', '${MARKET_US}'].includes(market)) continue; - const firstTd = tr.querySelector('td:first-child'); - if (!firstTd) continue; - const a = firstTd.querySelector('a'); - if (!a) continue; - let link = a.getAttribute('href') || ''; - if (link.startsWith('//')) link = 'https:' + link; - results.push({ stockName, market, symbol, link }); - } - for (const item of results) { - const name = item.stockName.toLowerCase(); - const key = searchKey.toLowerCase(); - let hitRate = 0; - if (name.includes(key)) hitRate = key.length / name.length; - if (hitRate >= 0.5) matchedRes.push({ url: item.link, market: item.market, hitRate }); - } - matchedRes.sort((a, b) => b.hitRate - a.hitRate); - if (matchedRes.length === 0) return null; - if (targetMarket !== 'auto') { - const candidates = matchedRes.filter(item => item.market === targetMarket); - if (candidates.length === 0) return null; - return candidates.reduce((best, curr) => curr.hitRate > best.hitRate ? curr : best); - } - const maxHitRate = Math.max(...matchedRes.map(item => item.hitRate)); - const topCandidates = matchedRes.filter(item => item.hitRate === maxHitRate); - for (const m of ['${MARKET_CN}', '${MARKET_HK}', '${MARKET_US}']) { - const found = topCandidates.find(item => item.market === m); - if (found) return found; - } - return topCandidates[0]; - })() - `); - - if (!suggestRes) { - throw new CliError('NOT_FOUND', `No stock found for "${args.key}"`, 'Try a different name or code'); + func: async (_page, args) => { + const key = String(args.key); + const market = String(args.market); + + const marketMap: Record = { + cn: [MARKET_CN], hk: [MARKET_HK], us: [MARKET_US], + auto: [MARKET_CN, MARKET_HK, MARKET_US], + }; + const targetMarkets = marketMap[market]; + if (!targetMarkets) { + throw new CliError('INPUT_ERROR', `Invalid market: "${market}"`, 'Expected cn, hk, us, or auto'); + } + + // 1. Search symbol + const suggestRaw = await fetchGBK( + `https://suggest3.sinajs.cn/suggest/type=11,31,41&key=${encodeURIComponent(key)}` + ); + const entries = parseSuggest(suggestRaw, targetMarkets); + if (!entries.length) { + throw new CliError('NOT_FOUND', `No stock found for "${key}"`, 'Try a different name, code, or --market'); + } + + // Pick best match: score by name similarity, tiebreak by market priority + const needle = key.toLowerCase(); + const score = (e: SuggestEntry): number => { + const n = e.name.toLowerCase(); + if (n === needle) return 1; + if (n.includes(needle)) return needle.length / n.length; + return 0; + }; + const best = entries.sort((a, b) => { + const d = score(b) - score(a); + return d !== 0 ? d : targetMarkets.indexOf(a.market) - targetMarkets.indexOf(b.market); + })[0]; + + // 2. Fetch quote + const sym = hqSymbol(best); + const hqRaw = await fetchGBK(`https://hq.sinajs.cn/list=${sym}`); + const f = parseHq(hqRaw, sym); + + if (f.length < 2 || !f[0]) { + throw new CliError('NOT_FOUND', `No quote data for "${key}"`, 'Market may be closed or data unavailable'); + } + + if (best.market === MARKET_CN) { + const price = parseFloat(f[3]); + const prev = parseFloat(f[2]); + const chg = (price - prev).toFixed(3); + const chgPct = ((price - prev) / prev * 100).toFixed(2) + '%'; + 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: '' }]; } - await page.goto((suggestRes as { url: string }).url); - await page.wait({ selector: '#hqDetails, .deta03, #hqPrice', timeout: 10000 }); - - const market = (suggestRes as { market: string }).market; - let payload: unknown; - - if (market === MARKET_HK) { - payload = await page.evaluate(` - (() => { - function getFieldValueFromLi(labelText) { - const li = Array.from(document.querySelectorAll('.deta03 li')) - .find(el => (el.textContent || '').replace(/[\\s\\uFEFF\\xA0]+$/g, '').startsWith(labelText)); - return li?.querySelector('span')?.textContent?.trim() || ''; - } - const changeText = document.getElementById('mts_stock_hk_zdf')?.textContent || ''; - const changeParts = changeText.replace(/[()()]/g, ' ').trim().split(/\\s+/); - return { - Symbol: document.getElementById('stock_sy')?.textContent || '', - Name: document.getElementById('stock_cname')?.textContent || '', - Price: document.getElementById('mts_stock_hk_price')?.textContent || '', - Change: changeParts[0] || '', - ChangePercent: changeParts[1] || '', - Open: getFieldValueFromLi('今开盘'), - High: getFieldValueFromLi('最高价'), - Low: getFieldValueFromLi('最低价'), - Volume: getFieldValueFromLi('成交量'), - MarketCap: getFieldValueFromLi('港股市值'), - }; - })() - `); - } else if (market === MARKET_CN) { - payload = await page.evaluate(` - (() => { - const getFieldValue = (labelText) => { - const th = Array.from(document.querySelectorAll('#hqDetails th')) - .find(el => el.textContent.trim().includes(labelText)); - return th?.nextElementSibling?.textContent?.trim() || ''; - }; - return { - Symbol: document.querySelector('#stockName span')?.textContent?.replace(/[()]/g, '') || '', - Name: document.querySelector('#stockName i')?.textContent || '', - Price: document.getElementById('price')?.textContent || '', - Change: document.getElementById('change')?.textContent || '', - ChangePercent: document.getElementById('changeP')?.textContent || '', - Open: getFieldValue('今 开'), - High: getFieldValue('最 高'), - Low: getFieldValue('最 低'), - Volume: getFieldValue('成交量'), - MarketCap: getFieldValue('总市值'), - }; - })() - `); - } else if (market === MARKET_US) { - payload = await page.evaluate(` - (() => { - const cleanText = (text) => text ? text.replace(/[\\t\\n\\r]/g, '').trim() : ''; - const h1Text = cleanText(document.querySelector('.name h1')?.textContent || ''); - const h1Parts = h1Text.split(/\\s+/); - const symbolText = (h1Parts[1] || '').split(':')[1] || ''; - const changeText = cleanText(document.querySelector('.hq_change')?.textContent || ''); - const changeMatch = changeText.match(/([+-]?\\d+\\.?\\d*)\\(([+-]?\\d+\\.?\\d*)%\\)/); - const getFieldValue = (labelText) => { - const th = Array.from(document.querySelectorAll('#hqDetails th')) - .find(el => el.textContent.trim().includes(labelText)); - return th?.nextElementSibling?.textContent?.trim() || ''; - }; - const rangeText = getFieldValue('区间'); - const rangeParts = rangeText.split('-'); - return { - Symbol: symbolText, - Name: h1Parts[0] || '', - Price: cleanText(document.getElementById('hqPrice')?.textContent), - Change: changeMatch ? changeMatch[1] : '', - ChangePercent: changeMatch ? changeMatch[2] + '%' : changeText, - Open: getFieldValue('开盘'), - High: rangeParts[1] ? cleanText(rangeParts[1]) : '', - Low: rangeParts[0] ? cleanText(rangeParts[0]) : '', - Volume: getFieldValue('成交量'), - MarketCap: getFieldValue('市值'), - }; - })() - `); - } else { - throw new CliError('NOT_FOUND', `Unsupported market code: ${market}`, 'Expected cn, hk, or us'); + if (best.market === MARKET_HK) { + // [2]=price [4]=high [5]=low [6]=open [7]=change [8]=change% [11]=volume + 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: '' }]; } - if (!payload || typeof payload !== 'object') return []; - return [payload]; + // MARKET_US: [1]=price [2]=change% [4]=change [6]=open [7]=today_low [8]=52wH [9]=52wL [10]=volume [12]=mktcap + 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]) }]; }, }); From 8090c8295d88935c8ae53555c12766a188518c4b Mon Sep 17 00:00:00 2001 From: jackwener Date: Sat, 28 Mar 2026 21:58:01 +0800 Subject: [PATCH 2/3] feat(exit-codes): add Unix-standard exit codes to all CliError types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce EXIT_CODES constant table (sysexits.h conventions) and wire exitCode into every CliError subclass so the process exit code reflects the semantic type of failure: 0 success (default) 1 generic / unexpected error 2 argument / usage error (ArgumentError) 66 empty result / not found (EmptyResultError, SelectorError) 69 service unavailable (BrowserConnectError, AdapterLoadError) 77 permission / auth required (AuthRequiredError) 78 configuration error (ConfigError) 124 timeout (TimeoutError) 130 Ctrl-C / SIGINT (unchanged, tui.ts) resolveExitCode() in commanderAdapter.ts reads err.exitCode for typed CliErrors, and falls back to pattern-matching message text for untyped adapter errors (auth pattern → 77, not-found pattern → 66, else → 1). Shell scripts can now distinguish error categories: opencli spotify status || echo "exit $?" # 69 if browser not running opencli github issues --repo x 2>/dev/null; [ $? -eq 77 ] && opencli github auth --- src/commanderAdapter.ts | 24 +++++++++++- src/errors.ts | 81 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 94 insertions(+), 11 deletions(-) diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index f76b85b0..4ecbdc83 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -18,6 +18,7 @@ import { render as renderOutput } from './output.js'; import { executeCommand } from './execution.js'; import { CliError, + EXIT_CODES, ERROR_ICONS, getErrorMessage, BrowserConnectError, @@ -117,11 +118,32 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi }); } catch (err) { await renderError(err, fullName(cmd), optionsRecord.verbose === true); - process.exitCode = 1; + process.exitCode = resolveExitCode(err); } }); } +// ── Exit code resolution ───────────────────────────────────────────────────── + +/** + * Map any thrown value to a Unix process exit code. + * + * - CliError subclasses carry their own exitCode (set in errors.ts). + * - Generic Error objects are classified by message pattern so that + * un-typed auth / not-found errors from adapters still produce + * meaningful exit codes for shell scripts. + */ +function resolveExitCode(err: unknown): number { + if (err instanceof CliError) return err.exitCode; + + // Pattern-based fallback for untyped errors thrown by third-party adapters. + const msg = getErrorMessage(err); + const kind = classifyGenericError(msg); + if (kind === 'auth') return EXIT_CODES.NOPERM; + if (kind === 'not-found') return EXIT_CODES.EMPTY_RESULT; + return EXIT_CODES.GENERIC_ERROR; +} + // ── Error rendering ────────────────────────────────────────────────────────── const ISSUES_URL = 'https://github.com/jackwener/opencli/issues'; diff --git a/src/errors.ts b/src/errors.ts index d34c8434..2a9697f7 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -4,48 +4,96 @@ * All errors thrown by the framework should extend CliError so that * the top-level handler in commanderAdapter.ts can render consistent, * helpful output with emoji-coded severity and actionable hints. + * + * ## Exit codes + * + * opencli follows Unix conventions (sysexits.h) for process exit codes: + * + * 0 Success + * 1 Generic / unexpected error + * 2 Argument / usage error (ArgumentError) + * 66 No input / empty result (EmptyResultError, SelectorError) + * 69 Service unavailable (BrowserConnectError, AdapterLoadError) + * 77 Permission denied / auth needed (AuthRequiredError) + * 78 Configuration error (ConfigError) + * 124 Timeout (TimeoutError) + * 130 Interrupted by Ctrl-C (set by tui.ts SIGINT handler) */ +// ── Exit code table ────────────────────────────────────────────────────────── + +export const EXIT_CODES = { + SUCCESS: 0, + GENERIC_ERROR: 1, + USAGE_ERROR: 2, // Bad arguments / command misuse + EMPTY_RESULT: 66, // No data / not found (EX_NOINPUT) + SERVICE_UNAVAIL:69, // Daemon / browser unavailable (EX_UNAVAILABLE) + NOPERM: 77, // Auth required / permission (EX_NOPERM) + CONFIG_ERROR: 78, // Missing / invalid config (EX_CONFIG) + TIMEOUT: 124, // Command timed out + INTERRUPTED: 130, // Ctrl-C / SIGINT +} as const; + +export type ExitCode = typeof EXIT_CODES[keyof typeof EXIT_CODES]; + +// ── Base class ─────────────────────────────────────────────────────────────── + export class CliError extends Error { /** Machine-readable error code (e.g. 'BROWSER_CONNECT', 'AUTH_REQUIRED') */ readonly code: string; /** Human-readable hint on how to fix the problem */ readonly hint?: string; + /** Unix process exit code — defaults to 1 (generic error) */ + readonly exitCode: ExitCode; - constructor(code: string, message: string, hint?: string) { + constructor(code: string, message: string, hint?: string, exitCode: ExitCode = EXIT_CODES.GENERIC_ERROR) { super(message); this.name = new.target.name; this.code = code; this.hint = hint; + this.exitCode = exitCode; } } +// ── Typed subclasses ───────────────────────────────────────────────────────── + export type BrowserConnectKind = 'daemon-not-running' | 'extension-not-connected' | 'command-failed' | 'unknown'; export class BrowserConnectError extends CliError { readonly kind: BrowserConnectKind; constructor(message: string, hint?: string, kind: BrowserConnectKind = 'unknown') { - super('BROWSER_CONNECT', message, hint); + super('BROWSER_CONNECT', message, hint, EXIT_CODES.SERVICE_UNAVAIL); this.kind = kind; } } export class AdapterLoadError extends CliError { - constructor(message: string, hint?: string) { super('ADAPTER_LOAD', message, hint); } + constructor(message: string, hint?: string) { + super('ADAPTER_LOAD', message, hint, EXIT_CODES.SERVICE_UNAVAIL); + } } export class CommandExecutionError extends CliError { - constructor(message: string, hint?: string) { super('COMMAND_EXEC', message, hint); } + constructor(message: string, hint?: string) { + super('COMMAND_EXEC', message, hint, EXIT_CODES.GENERIC_ERROR); + } } export class ConfigError extends CliError { - constructor(message: string, hint?: string) { super('CONFIG', message, hint); } + constructor(message: string, hint?: string) { + super('CONFIG', message, hint, EXIT_CODES.CONFIG_ERROR); + } } export class AuthRequiredError extends CliError { readonly domain: string; constructor(domain: string, message?: string) { - super('AUTH_REQUIRED', message ?? `Not logged in to ${domain}`, `Please open Chrome and log in to https://${domain}`); + super( + 'AUTH_REQUIRED', + message ?? `Not logged in to ${domain}`, + `Please open Chrome and log in to https://${domain}`, + EXIT_CODES.NOPERM, + ); this.domain = domain; } } @@ -56,27 +104,40 @@ export class TimeoutError extends CliError { 'TIMEOUT', `${label} timed out after ${seconds}s`, hint ?? 'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var', + EXIT_CODES.TIMEOUT, ); } } export class ArgumentError extends CliError { - constructor(message: string, hint?: string) { super('ARGUMENT', message, hint); } + constructor(message: string, hint?: string) { + super('ARGUMENT', message, hint, EXIT_CODES.USAGE_ERROR); + } } export class EmptyResultError extends CliError { constructor(command: string, hint?: string) { - super('EMPTY_RESULT', `${command} returned no data`, hint ?? 'The page structure may have changed, or you may need to log in'); + super( + 'EMPTY_RESULT', + `${command} returned no data`, + hint ?? 'The page structure may have changed, or you may need to log in', + EXIT_CODES.EMPTY_RESULT, + ); } } export class SelectorError extends CliError { constructor(selector: string, hint?: string) { - super('SELECTOR', `Could not find element: ${selector}`, hint ?? 'The page UI may have changed. Please report this issue.'); + super( + 'SELECTOR', + `Could not find element: ${selector}`, + hint ?? 'The page UI may have changed. Please report this issue.', + EXIT_CODES.EMPTY_RESULT, + ); } } -// ── Utilities ─────────────────────────────────────────────────────────── +// ── Utilities ─────────────────────────────────────────────────────────────── /** Extract a human-readable message from an unknown caught value. */ export function getErrorMessage(error: unknown): string { From be9ea2bfb0e5d9a2579ba6ef9a61cc8e8b307110 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sat, 28 Mar 2026 21:58:29 +0800 Subject: [PATCH 3/3] review: regex escape sym, fix change precision, optimize suggest type param --- src/clis/sinafinance/stock.ts | 9 ++-- src/commanderAdapter.ts | 24 +---------- src/errors.ts | 81 +++++------------------------------ 3 files changed, 16 insertions(+), 98 deletions(-) diff --git a/src/clis/sinafinance/stock.ts b/src/clis/sinafinance/stock.ts index a408e527..b5f61a57 100644 --- a/src/clis/sinafinance/stock.ts +++ b/src/clis/sinafinance/stock.ts @@ -38,7 +38,8 @@ function hqSymbol(e: SuggestEntry): string { } function parseHq(raw: string, sym: string): string[] { - const m = raw.match(new RegExp(`hq_str_${sym}="([^"]*)"`)); + const escaped = sym.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const m = raw.match(new RegExp(`hq_str_${escaped}="([^"]*)"`)); return m ? m[1].split(',') : []; } @@ -76,9 +77,9 @@ cli({ throw new CliError('INPUT_ERROR', `Invalid market: "${market}"`, 'Expected cn, hk, us, or auto'); } - // 1. Search symbol + // 1. Search symbol — only request the markets we care about const suggestRaw = await fetchGBK( - `https://suggest3.sinajs.cn/suggest/type=11,31,41&key=${encodeURIComponent(key)}` + `https://suggest3.sinajs.cn/suggest/type=${targetMarkets.join(',')}&key=${encodeURIComponent(key)}` ); const entries = parseSuggest(suggestRaw, targetMarkets); if (!entries.length) { @@ -110,7 +111,7 @@ cli({ if (best.market === MARKET_CN) { const price = parseFloat(f[3]); const prev = parseFloat(f[2]); - const chg = (price - prev).toFixed(3); + const chg = (price - prev).toFixed(2); const chgPct = ((price - prev) / prev * 100).toFixed(2) + '%'; 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: '' }]; } diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index 4ecbdc83..f76b85b0 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -18,7 +18,6 @@ import { render as renderOutput } from './output.js'; import { executeCommand } from './execution.js'; import { CliError, - EXIT_CODES, ERROR_ICONS, getErrorMessage, BrowserConnectError, @@ -118,32 +117,11 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi }); } catch (err) { await renderError(err, fullName(cmd), optionsRecord.verbose === true); - process.exitCode = resolveExitCode(err); + process.exitCode = 1; } }); } -// ── Exit code resolution ───────────────────────────────────────────────────── - -/** - * Map any thrown value to a Unix process exit code. - * - * - CliError subclasses carry their own exitCode (set in errors.ts). - * - Generic Error objects are classified by message pattern so that - * un-typed auth / not-found errors from adapters still produce - * meaningful exit codes for shell scripts. - */ -function resolveExitCode(err: unknown): number { - if (err instanceof CliError) return err.exitCode; - - // Pattern-based fallback for untyped errors thrown by third-party adapters. - const msg = getErrorMessage(err); - const kind = classifyGenericError(msg); - if (kind === 'auth') return EXIT_CODES.NOPERM; - if (kind === 'not-found') return EXIT_CODES.EMPTY_RESULT; - return EXIT_CODES.GENERIC_ERROR; -} - // ── Error rendering ────────────────────────────────────────────────────────── const ISSUES_URL = 'https://github.com/jackwener/opencli/issues'; diff --git a/src/errors.ts b/src/errors.ts index 2a9697f7..d34c8434 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -4,96 +4,48 @@ * All errors thrown by the framework should extend CliError so that * the top-level handler in commanderAdapter.ts can render consistent, * helpful output with emoji-coded severity and actionable hints. - * - * ## Exit codes - * - * opencli follows Unix conventions (sysexits.h) for process exit codes: - * - * 0 Success - * 1 Generic / unexpected error - * 2 Argument / usage error (ArgumentError) - * 66 No input / empty result (EmptyResultError, SelectorError) - * 69 Service unavailable (BrowserConnectError, AdapterLoadError) - * 77 Permission denied / auth needed (AuthRequiredError) - * 78 Configuration error (ConfigError) - * 124 Timeout (TimeoutError) - * 130 Interrupted by Ctrl-C (set by tui.ts SIGINT handler) */ -// ── Exit code table ────────────────────────────────────────────────────────── - -export const EXIT_CODES = { - SUCCESS: 0, - GENERIC_ERROR: 1, - USAGE_ERROR: 2, // Bad arguments / command misuse - EMPTY_RESULT: 66, // No data / not found (EX_NOINPUT) - SERVICE_UNAVAIL:69, // Daemon / browser unavailable (EX_UNAVAILABLE) - NOPERM: 77, // Auth required / permission (EX_NOPERM) - CONFIG_ERROR: 78, // Missing / invalid config (EX_CONFIG) - TIMEOUT: 124, // Command timed out - INTERRUPTED: 130, // Ctrl-C / SIGINT -} as const; - -export type ExitCode = typeof EXIT_CODES[keyof typeof EXIT_CODES]; - -// ── Base class ─────────────────────────────────────────────────────────────── - export class CliError extends Error { /** Machine-readable error code (e.g. 'BROWSER_CONNECT', 'AUTH_REQUIRED') */ readonly code: string; /** Human-readable hint on how to fix the problem */ readonly hint?: string; - /** Unix process exit code — defaults to 1 (generic error) */ - readonly exitCode: ExitCode; - constructor(code: string, message: string, hint?: string, exitCode: ExitCode = EXIT_CODES.GENERIC_ERROR) { + constructor(code: string, message: string, hint?: string) { super(message); this.name = new.target.name; this.code = code; this.hint = hint; - this.exitCode = exitCode; } } -// ── Typed subclasses ───────────────────────────────────────────────────────── - export type BrowserConnectKind = 'daemon-not-running' | 'extension-not-connected' | 'command-failed' | 'unknown'; export class BrowserConnectError extends CliError { readonly kind: BrowserConnectKind; constructor(message: string, hint?: string, kind: BrowserConnectKind = 'unknown') { - super('BROWSER_CONNECT', message, hint, EXIT_CODES.SERVICE_UNAVAIL); + super('BROWSER_CONNECT', message, hint); this.kind = kind; } } export class AdapterLoadError extends CliError { - constructor(message: string, hint?: string) { - super('ADAPTER_LOAD', message, hint, EXIT_CODES.SERVICE_UNAVAIL); - } + constructor(message: string, hint?: string) { super('ADAPTER_LOAD', message, hint); } } export class CommandExecutionError extends CliError { - constructor(message: string, hint?: string) { - super('COMMAND_EXEC', message, hint, EXIT_CODES.GENERIC_ERROR); - } + constructor(message: string, hint?: string) { super('COMMAND_EXEC', message, hint); } } export class ConfigError extends CliError { - constructor(message: string, hint?: string) { - super('CONFIG', message, hint, EXIT_CODES.CONFIG_ERROR); - } + constructor(message: string, hint?: string) { super('CONFIG', message, hint); } } export class AuthRequiredError extends CliError { readonly domain: string; constructor(domain: string, message?: string) { - super( - 'AUTH_REQUIRED', - message ?? `Not logged in to ${domain}`, - `Please open Chrome and log in to https://${domain}`, - EXIT_CODES.NOPERM, - ); + super('AUTH_REQUIRED', message ?? `Not logged in to ${domain}`, `Please open Chrome and log in to https://${domain}`); this.domain = domain; } } @@ -104,40 +56,27 @@ export class TimeoutError extends CliError { 'TIMEOUT', `${label} timed out after ${seconds}s`, hint ?? 'Try again, or increase timeout with OPENCLI_BROWSER_COMMAND_TIMEOUT env var', - EXIT_CODES.TIMEOUT, ); } } export class ArgumentError extends CliError { - constructor(message: string, hint?: string) { - super('ARGUMENT', message, hint, EXIT_CODES.USAGE_ERROR); - } + constructor(message: string, hint?: string) { super('ARGUMENT', message, hint); } } export class EmptyResultError extends CliError { constructor(command: string, hint?: string) { - super( - 'EMPTY_RESULT', - `${command} returned no data`, - hint ?? 'The page structure may have changed, or you may need to log in', - EXIT_CODES.EMPTY_RESULT, - ); + super('EMPTY_RESULT', `${command} returned no data`, hint ?? 'The page structure may have changed, or you may need to log in'); } } export class SelectorError extends CliError { constructor(selector: string, hint?: string) { - super( - 'SELECTOR', - `Could not find element: ${selector}`, - hint ?? 'The page UI may have changed. Please report this issue.', - EXIT_CODES.EMPTY_RESULT, - ); + super('SELECTOR', `Could not find element: ${selector}`, hint ?? 'The page UI may have changed. Please report this issue.'); } } -// ── Utilities ─────────────────────────────────────────────────────────────── +// ── Utilities ─────────────────────────────────────────────────────────── /** Extract a human-readable message from an unknown caught value. */ export function getErrorMessage(error: unknown): string {