From 90d2a9ff7ddf828a5a98cc048a79b18b3b62429c Mon Sep 17 00:00:00 2001 From: yichuan Date: Sat, 28 Mar 2026 13:31:46 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=96=B0=E6=B5=AA?= =?UTF-8?q?=E8=B4=A2=E7=BB=8F=E8=A1=8C=E6=83=85=E5=8F=8A=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E6=96=B0=E9=97=BB=E6=8A=93=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/adapters/browser/sinafinance.md | 62 +++++++- src/clis/sinafinance/rolling-news.ts | 81 ++++++++++ src/clis/sinafinance/stock.ts | 212 +++++++++++++++++++++++++++ 3 files changed, 349 insertions(+), 6 deletions(-) create mode 100644 src/clis/sinafinance/rolling-news.ts create mode 100644 src/clis/sinafinance/stock.ts diff --git a/docs/adapters/browser/sinafinance.md b/docs/adapters/browser/sinafinance.md index 4512f5b2..488b2d89 100644 --- a/docs/adapters/browser/sinafinance.md +++ b/docs/adapters/browser/sinafinance.md @@ -1,15 +1,19 @@ # 新浪财经 (Sina Finance) -**Mode**: 🌐 Public · **Domain**: `finance.sina.com.cn` +**Mode**: 🌐 Public / 🔐 Browser · **Domain**: `finance.sina.com.cn` ## Commands -| Command | Description | -|---------|-------------| -| `opencli sinafinance news` | 新浪财经 7×24 小时实时快讯 | +| Command | Description | Mode | +|---------|-------------|------| +| `opencli sinafinance news` | 新浪财经 7×24 小时实时快讯 | 🌐 Public | +| `opencli sinafinance rolling-news` | 新浪财经滚动新闻 | 🔐 Browser | +| `opencli sinafinance stock` | 新浪财经行情(A股/港股/美股) | 🔐 Browser | ## Usage Examples +### news - 7×24 实时快讯 + ```bash # Latest financial news opencli sinafinance news --limit 20 @@ -23,13 +27,59 @@ opencli sinafinance news --type 6 # 国际 opencli sinafinance news -f json ``` -### Options +### rolling-news - 滚动新闻 + +```bash +# Rolling news feed +opencli sinafinance rolling-news + +# JSON output +opencli sinafinance rolling-news -f json +``` + +### stock - 股票行情 + +```bash +# Search and view A-share stock +opencli sinafinance stock 贵州茅台 --market cn + +# Search and view HK stock +opencli sinafinance stock 腾讯控股 --market hk + +# Search and view US stock +opencli sinafinance stock aapl --market us + +# Auto-detect market (searches cn, hk, us in order) +opencli sinafinance stock 招商证券 + +# JSON output +opencli sinafinance stock 贵州茅台 -f json +``` + +## Options + +### news | Option | Description | |--------|-------------| | `--limit` | Max results, up to 50 (default: 20) | | `--type` | News type: `0`=全部, `1`=A股, `2`=宏观, `3`=公司, `4`=数据, `5`=市场, `6`=国际, `7`=观点, `8`=央行, `9`=其它 | +### stock + +| 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 -- No browser required — uses public API +- `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 + +## 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 +- Market priority for auto-detection: cn (A股) → hk (港股) → us (美股) diff --git a/src/clis/sinafinance/rolling-news.ts b/src/clis/sinafinance/rolling-news.ts new file mode 100644 index 00000000..6d30d119 --- /dev/null +++ b/src/clis/sinafinance/rolling-news.ts @@ -0,0 +1,81 @@ +/** + * Sinafinance rolling news feed + */ + +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; +function dateToTimestampParams(dateStr: string): string { + // 验证日期格式(简单校验) + if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { + // throw new Error('Invalid date format. Expected YYYY-MM-DD.'); + throw new CliError('INPUT_ERROR', `Invalid date format`, 'Expected YYYY-MM-DD.'); + + } + // 创建 Date 对象(注意:new Date('2026-03-24') 在 JS 中默认解析为 UTC 时间的 00:00:00) + const dt = new Date(dateStr); + + // 检查是否是无效日期 + if (isNaN(dt.getTime())) { + // throw new Error('Invalid date.'); + throw new CliError('INPUT_ERROR', `Invalid date`, 'Expected YYYY-MM-DD.'); + } + + // 获取 stime:当天 00:00:00 UTC 的时间戳(秒) + const stime = Math.floor(dt.getTime() / 1000); + + // etime:前一天 00:00:00 UTC + const prevDay = new Date(dt); + prevDay.setDate(prevDay.getDate() - 1); + const etime = Math.floor(prevDay.getTime() / 1000); + + // ctime = stime + const ctime = stime; + + return `&etime=${etime}&stime=${stime}&ctime=${ctime}`; +} +cli({ + site: 'sinafinance', + name: 'rolling-news',//latest-news + description: '新浪财经滚动新闻', + domain: 'finance.sina.com.cn/roll', + strategy: Strategy.COOKIE, + args: [ + // { name: 'date', type: 'string', required: false, help: 'date to search, format as YYYY-MM-dd' }, + // { name: 'page', type: 'string', default: 1, help: 'Number of page' }, + // { name: 'lid', type: 'string', default: 2519, help: 'News type: 2519=财经 2671=股市 2672=美股 2673=中概股 2674=港股 2675=研究报告 2676=全球市场 2487=外汇' }, + ], + columns: ['clomn', 'title', 'date', 'url'], + func: async (page, args) => { + const dateStr = args.date ? dateToTimestampParams(args.date) : ''; + // console.log(`https://finance.sina.com.cn/roll/#pageid=384&lid=${args.lid}&k=&page=${args.page}${dateStr}`) + await page.goto( + // `https://finance.sina.com.cn/roll/#pageid=384&lid=${args.lid}&k=&page=${args.page}${dateStr}`, + `https://finance.sina.com.cn/roll/#pageid=384&lid=2519`, + ); + await page.wait(5); + + const payload = await page.evaluate(` + (() => { + const cleanText = (value) => (value || '').replace(/\\s+/g, ' ').trim(); + const results = []; + document.querySelectorAll('.d_list_txt li').forEach(el => { + const titleEl = el.querySelector('.c_tit a'); + const clomnEl = el.querySelector('.c_chl'); + const dateEl = el.querySelector('.c_time'); + const url = titleEl?.getAttribute('href') || ''; + if (!url) return; + results.push({ + title: cleanText(titleEl?.textContent || ''), + clomn: cleanText(clomnEl?.textContent || ''), + date: cleanText(dateEl?.textContent || '0'), + url: url, + }); + }); + return results; + })() + `); + if (!payload || typeof payload !== 'object') return []; + const data: any[] = Array.isArray(payload) ? payload : []; + return data + }, +}); diff --git a/src/clis/sinafinance/stock.ts b/src/clis/sinafinance/stock.ts new file mode 100644 index 00000000..e87aaaca --- /dev/null +++ b/src/clis/sinafinance/stock.ts @@ -0,0 +1,212 @@ +/** + * Sinafinance quote stock + * a股 港股 美股 + */ + +import { cli, Strategy } from '../../registry.js'; +import { CliError } from '../../errors.js'; + +cli({ + site: 'sinafinance', + name: 'stock', + description: '新浪财经行情', + domain: 'finance.sina.cn', + strategy: Strategy.COOKIE, + 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时按cn,hk,us的顺序匹配' } + ], + columns: ['Symbol','Name', 'Price', 'Change', 'ChangePercent', 'Open', 'High', 'Low', 'Volume', 'MarketCap'], + func: async (page, args) => { + // 1. 打开搜索页面 + await page.goto('https://finance.sina.com.cn/stock/'); + await page.wait(5); + // 获取table元素(fcSuggest_140418的哥哥节点) + const suggestRes = await page.evaluate(` + (async() => { + const searchKey = '${args.key}'; + const searchMarket = '${args.market}'; + 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 new Promise(r => setTimeout(r, 100)); + } + throw new Error('Timeout waiting for '+selector); + }; + const inputEl = document.getElementById('suggest01_input'); + 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: '11', hk: '31', us: '41' }; + 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 (!['11', '31', '41'].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: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); + const marketPriority = ['11', '31', '41']; + for (const market of marketPriority) { + const found = topCandidates.find(item => item.market === market); + if (found) return found; + } + return topCandidates[0]; + })() + `); + if (!suggestRes) return []; + await page.goto(suggestRes.url); + await page.wait(5); + + let payload; + if (suggestRes.market === '31') { + // 港股:获取行情数据 + payload = await page.evaluate(` + (() => { + function getFieldValueFromLi(labelText) { + const li = Array.from(document.querySelectorAll('.deta03 li')) + .find(el => { + const directText = (el.textContent || '').replace(/[\s\uFEFF\xA0]+$/g, ''); + return directText.startsWith(labelText); + }); + return li?.querySelector('span')?.textContent?.trim() || null; + } + const stockData = { + Symbol: document.getElementById('stock_sy')?.textContent || '', + Name: document.getElementById('stock_cname')?.textContent || '', + Price: document.getElementById('mts_stock_hk_price')?.textContent || '', + Change: '', + ChangePercent: document.getElementById('mts_stock_hk_zdf')?.textContent || '', + Open: getFieldValueFromLi('今开盘'), + High: getFieldValueFromLi('最高价'), + Low: getFieldValueFromLi('最低价'), + Volume: getFieldValueFromLi('成交量'), + MarketCap: getFieldValueFromLi('港股市值') + }; + const changeText = stockData.ChangePercent; + const changeParts = changeText.replace(/[()()]/g, ' ').trim().split(' '); + stockData.Change = changeParts[0] || ''; + stockData.ChangePercent = changeParts[1] || ''; + return stockData; + })() + `); + } else if (suggestRes.market === '11') { + // A股:获取行情数据 + payload = await page.evaluate(` + (() => { + const getFieldValue = (labelText) => { + const th = Array.from(document.querySelectorAll('#hqDetails th')) + .find(el => el.textContent.trim().indexOf(labelText) > -1); + return th?.nextElementSibling?.textContent?.trim() || ''; + }; + const stockData = { + 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('总市值'), + }; + return stockData; + })() + `); + } else if (suggestRes.market === '41') { + // 美股:获取行情数据 + 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 nameText = h1Parts[0] || ''; + const symbolText = h1Parts[1] || ''; + const symbol = symbolText.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().indexOf(labelText) > -1); + return th?.nextElementSibling?.textContent?.trim() || ''; + }; + const rangeText = getFieldValue('区间'); + const rangeParts = rangeText.split('-'); + const stockData = { + Symbol: symbol, + Name: nameText, + 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('市值') + }; + + return stockData; + })() + `); + } else { + payload = {}; + } + if (!payload || typeof payload !== 'object') return []; + const data: any[] = [payload]; + return data; + }, +}); From 469365fbe0751e626443fd1a09d0089e1430f4a2 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sat, 28 Mar 2026 21:45:39 +0800 Subject: [PATCH 2/2] review: fix injection vuln, dead code, typos, hardcoded waits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rolling-news: - Remove dead dateToTimestampParams function and unused CliError import - Fix column field name typo: clomn → column - Replace page.wait(5) with selector-based wait - Remove all commented-out code stock: - Fix P0 JS injection: use JSON.stringify() to safely embed args.key/market - Add null guard for inputEl before calling .focus() - waitForElement returns null instead of throwing on timeout - Replace page.wait(5) with selector-based wait - Extract MARKET_CN/HK/US as named constants - Throw CliError on NOT_FOUND instead of silent empty return --- src/clis/sinafinance/rolling-news.ts | 61 ++---------- src/clis/sinafinance/stock.ts | 144 ++++++++++++--------------- 2 files changed, 75 insertions(+), 130 deletions(-) diff --git a/src/clis/sinafinance/rolling-news.ts b/src/clis/sinafinance/rolling-news.ts index 6d30d119..9e0cac9d 100644 --- a/src/clis/sinafinance/rolling-news.ts +++ b/src/clis/sinafinance/rolling-news.ts @@ -3,56 +3,18 @@ */ import { cli, Strategy } from '../../registry.js'; -import { CliError } from '../../errors.js'; -function dateToTimestampParams(dateStr: string): string { - // 验证日期格式(简单校验) - if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { - // throw new Error('Invalid date format. Expected YYYY-MM-DD.'); - throw new CliError('INPUT_ERROR', `Invalid date format`, 'Expected YYYY-MM-DD.'); - } - // 创建 Date 对象(注意:new Date('2026-03-24') 在 JS 中默认解析为 UTC 时间的 00:00:00) - const dt = new Date(dateStr); - - // 检查是否是无效日期 - if (isNaN(dt.getTime())) { - // throw new Error('Invalid date.'); - throw new CliError('INPUT_ERROR', `Invalid date`, 'Expected YYYY-MM-DD.'); - } - - // 获取 stime:当天 00:00:00 UTC 的时间戳(秒) - const stime = Math.floor(dt.getTime() / 1000); - - // etime:前一天 00:00:00 UTC - const prevDay = new Date(dt); - prevDay.setDate(prevDay.getDate() - 1); - const etime = Math.floor(prevDay.getTime() / 1000); - - // ctime = stime - const ctime = stime; - - return `&etime=${etime}&stime=${stime}&ctime=${ctime}`; -} cli({ site: 'sinafinance', - name: 'rolling-news',//latest-news + name: 'rolling-news', description: '新浪财经滚动新闻', domain: 'finance.sina.com.cn/roll', strategy: Strategy.COOKIE, - args: [ - // { name: 'date', type: 'string', required: false, help: 'date to search, format as YYYY-MM-dd' }, - // { name: 'page', type: 'string', default: 1, help: 'Number of page' }, - // { name: 'lid', type: 'string', default: 2519, help: 'News type: 2519=财经 2671=股市 2672=美股 2673=中概股 2674=港股 2675=研究报告 2676=全球市场 2487=外汇' }, - ], - columns: ['clomn', 'title', 'date', 'url'], - func: async (page, args) => { - const dateStr = args.date ? dateToTimestampParams(args.date) : ''; - // console.log(`https://finance.sina.com.cn/roll/#pageid=384&lid=${args.lid}&k=&page=${args.page}${dateStr}`) - await page.goto( - // `https://finance.sina.com.cn/roll/#pageid=384&lid=${args.lid}&k=&page=${args.page}${dateStr}`, - `https://finance.sina.com.cn/roll/#pageid=384&lid=2519`, - ); - await page.wait(5); + args: [], + columns: ['column', 'title', 'date', 'url'], + func: async (page, _args) => { + await page.goto(`https://finance.sina.com.cn/roll/#pageid=384&lid=2519`); + await page.wait({ selector: '.d_list_txt li', timeout: 10000 }); const payload = await page.evaluate(` (() => { @@ -60,22 +22,21 @@ cli({ const results = []; document.querySelectorAll('.d_list_txt li').forEach(el => { const titleEl = el.querySelector('.c_tit a'); - const clomnEl = el.querySelector('.c_chl'); + const columnEl = el.querySelector('.c_chl'); const dateEl = el.querySelector('.c_time'); const url = titleEl?.getAttribute('href') || ''; if (!url) return; results.push({ title: cleanText(titleEl?.textContent || ''), - clomn: cleanText(clomnEl?.textContent || ''), - date: cleanText(dateEl?.textContent || '0'), + column: cleanText(columnEl?.textContent || ''), + date: cleanText(dateEl?.textContent || ''), url: url, }); }); return results; })() `); - if (!payload || typeof payload !== 'object') return []; - const data: any[] = Array.isArray(payload) ? payload : []; - return data + if (!Array.isArray(payload)) return []; + return payload; }, }); diff --git a/src/clis/sinafinance/stock.ts b/src/clis/sinafinance/stock.ts index e87aaaca..38fc95f7 100644 --- a/src/clis/sinafinance/stock.ts +++ b/src/clis/sinafinance/stock.ts @@ -1,11 +1,15 @@ /** * Sinafinance quote stock - * a股 港股 美股 + * A股 / 港股 / 美股 */ import { cli, Strategy } from '../../registry.js'; import { CliError } from '../../errors.js'; +const MARKET_CN = '11'; +const MARKET_HK = '31'; +const MARKET_US = '41'; + cli({ site: 'sinafinance', name: 'stock', @@ -13,41 +17,45 @@ cli({ domain: 'finance.sina.cn', strategy: Strategy.COOKIE, 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时按cn,hk,us的顺序匹配' } + { 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' }, ], - columns: ['Symbol','Name', 'Price', 'Change', 'ChangePercent', 'Open', 'High', 'Low', 'Volume', 'MarketCap'], + columns: ['Symbol', 'Name', 'Price', 'Change', 'ChangePercent', 'Open', 'High', 'Low', 'Volume', 'MarketCap'], func: async (page, args) => { - // 1. 打开搜索页面 await page.goto('https://finance.sina.com.cn/stock/'); - await page.wait(5); - // 获取table元素(fcSuggest_140418的哥哥节点) + 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 = '${args.key}'; - const searchMarket = '${args.market}'; + 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 new Promise(r => setTimeout(r, 100)); + await sleep(100); } - throw new Error('Timeout waiting for '+selector); + 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) + 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: '11', hk: '31', us: '41' }; + const marketMap = { cn: '${MARKET_CN}', hk: '${MARKET_HK}', us: '${MARKET_US}' }; const targetMarket = marketMap[searchMarket] || 'auto'; const results = []; const matchedRes = []; @@ -59,136 +67,114 @@ cli({ const stockName = idParts[0] || ''; const market = idParts[1] || ''; const symbol = idParts[3] || ''; - if (!['11', '31', '41'].includes(market)) continue; + 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}); + 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:hitRate - }) - } + 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 - ); + 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); - const marketPriority = ['11', '31', '41']; - for (const market of marketPriority) { - const found = topCandidates.find(item => item.market === market); + 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) return []; - await page.goto(suggestRes.url); - await page.wait(5); - let payload; - if (suggestRes.market === '31') { - // 港股:获取行情数据 + if (!suggestRes) { + throw new CliError('NOT_FOUND', `No stock found for "${args.key}"`, 'Try a different name or code'); + } + + 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 => { - const directText = (el.textContent || '').replace(/[\s\uFEFF\xA0]+$/g, ''); - return directText.startsWith(labelText); - }); - return li?.querySelector('span')?.textContent?.trim() || null; + .find(el => (el.textContent || '').replace(/[\\s\\uFEFF\\xA0]+$/g, '').startsWith(labelText)); + return li?.querySelector('span')?.textContent?.trim() || ''; } - const stockData = { + 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: '', - ChangePercent: document.getElementById('mts_stock_hk_zdf')?.textContent || '', + Change: changeParts[0] || '', + ChangePercent: changeParts[1] || '', Open: getFieldValueFromLi('今开盘'), High: getFieldValueFromLi('最高价'), Low: getFieldValueFromLi('最低价'), Volume: getFieldValueFromLi('成交量'), - MarketCap: getFieldValueFromLi('港股市值') + MarketCap: getFieldValueFromLi('港股市值'), }; - const changeText = stockData.ChangePercent; - const changeParts = changeText.replace(/[()()]/g, ' ').trim().split(' '); - stockData.Change = changeParts[0] || ''; - stockData.ChangePercent = changeParts[1] || ''; - return stockData; })() `); - } else if (suggestRes.market === '11') { - // A股:获取行情数据 + } 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().indexOf(labelText) > -1); + .find(el => el.textContent.trim().includes(labelText)); return th?.nextElementSibling?.textContent?.trim() || ''; }; - const stockData = { + 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('最  低'), + Open: getFieldValue('今 开'), + High: getFieldValue('最 高'), + Low: getFieldValue('最 低'), Volume: getFieldValue('成交量'), MarketCap: getFieldValue('总市值'), }; - return stockData; })() `); - } else if (suggestRes.market === '41') { - // 美股:获取行情数据 + } 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 nameText = h1Parts[0] || ''; - const symbolText = h1Parts[1] || ''; - const symbol = symbolText.split(':')[1] || ''; - + 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().indexOf(labelText) > -1); + .find(el => el.textContent.trim().includes(labelText)); return th?.nextElementSibling?.textContent?.trim() || ''; }; const rangeText = getFieldValue('区间'); const rangeParts = rangeText.split('-'); - const stockData = { - Symbol: symbol, - Name: nameText, + return { + Symbol: symbolText, + Name: h1Parts[0] || '', Price: cleanText(document.getElementById('hqPrice')?.textContent), Change: changeMatch ? changeMatch[1] : '', ChangePercent: changeMatch ? changeMatch[2] + '%' : changeText, @@ -196,17 +182,15 @@ cli({ High: rangeParts[1] ? cleanText(rangeParts[1]) : '', Low: rangeParts[0] ? cleanText(rangeParts[0]) : '', Volume: getFieldValue('成交量'), - MarketCap: getFieldValue('市值') + MarketCap: getFieldValue('市值'), }; - - return stockData; })() `); } else { - payload = {}; + throw new CliError('NOT_FOUND', `Unsupported market code: ${market}`, 'Expected cn, hk, or us'); } + if (!payload || typeof payload !== 'object') return []; - const data: any[] = [payload]; - return data; + return [payload]; }, });