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..b5f61a57 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,115 @@ 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 escaped = sym.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const m = raw.match(new RegExp(`hq_str_${escaped}="([^"]*)"`)); + 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 — only request the markets we care about + const suggestRaw = await fetchGBK( + `https://suggest3.sinajs.cn/suggest/type=${targetMarkets.join(',')}&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(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: '' }]; } - 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]) }]; }, });