|
| 1 | +/** |
| 2 | + * Sinafinance quote stock |
| 3 | + * A股 / 港股 / 美股 |
| 4 | + */ |
| 5 | + |
| 6 | +import { cli, Strategy } from '../../registry.js'; |
| 7 | +import { CliError } from '../../errors.js'; |
| 8 | + |
| 9 | +const MARKET_CN = '11'; |
| 10 | +const MARKET_HK = '31'; |
| 11 | +const MARKET_US = '41'; |
| 12 | + |
| 13 | +cli({ |
| 14 | + site: 'sinafinance', |
| 15 | + name: 'stock', |
| 16 | + description: '新浪财经行情', |
| 17 | + domain: 'finance.sina.cn', |
| 18 | + strategy: Strategy.COOKIE, |
| 19 | + args: [ |
| 20 | + { name: 'key', type: 'string', required: true, positional: true, help: 'stock name or code to search' }, |
| 21 | + { name: 'market', type: 'string', default: 'auto', help: 'Market: cn, hk, us, auto(default). auto searches cn → hk → us in order' }, |
| 22 | + ], |
| 23 | + columns: ['Symbol', 'Name', 'Price', 'Change', 'ChangePercent', 'Open', 'High', 'Low', 'Volume', 'MarketCap'], |
| 24 | + func: async (page, args) => { |
| 25 | + await page.goto('https://finance.sina.com.cn/stock/'); |
| 26 | + await page.wait({ selector: '#suggest01_input', timeout: 10000 }); |
| 27 | + |
| 28 | + // Use JSON.stringify to safely pass user input into the browser context |
| 29 | + const searchKey = JSON.stringify(String(args.key)); |
| 30 | + const searchMarket = JSON.stringify(String(args.market)); |
| 31 | + |
| 32 | + const suggestRes = await page.evaluate(` |
| 33 | + (async() => { |
| 34 | + const searchKey = ${searchKey}; |
| 35 | + const searchMarket = ${searchMarket}; |
| 36 | + const sleep = (ms) => new Promise(r => setTimeout(r, ms)); |
| 37 | + const waitForElement = async (selector, timeout = 5000) => { |
| 38 | + const start = Date.now(); |
| 39 | + while (Date.now() - start < timeout) { |
| 40 | + const el = document.querySelector(selector); |
| 41 | + if (el) return el; |
| 42 | + await sleep(100); |
| 43 | + } |
| 44 | + return null; |
| 45 | + }; |
| 46 | + const inputEl = document.getElementById('suggest01_input'); |
| 47 | + if (!inputEl) return null; |
| 48 | + inputEl.focus(); |
| 49 | + inputEl.value = searchKey; |
| 50 | + inputEl.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: '0', code: 'Digit0' })); |
| 51 | + inputEl.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: '0', code: 'Digit0' })); |
| 52 | + inputEl.dispatchEvent(new Event('change', { bubbles: true })); |
| 53 | + await sleep(500); |
| 54 | + const suggestDOM = await waitForElement('#fcSuggest_140418'); |
| 55 | + if (!suggestDOM) return null; |
| 56 | + const table = suggestDOM.previousElementSibling; |
| 57 | + if (!table || table.tagName !== 'TABLE') return null; |
| 58 | + const marketMap = { cn: '${MARKET_CN}', hk: '${MARKET_HK}', us: '${MARKET_US}' }; |
| 59 | + const targetMarket = marketMap[searchMarket] || 'auto'; |
| 60 | + const results = []; |
| 61 | + const matchedRes = []; |
| 62 | + const rows = table.querySelectorAll('tr'); |
| 63 | + for (const tr of rows) { |
| 64 | + const id = tr.id; |
| 65 | + if (!id) continue; |
| 66 | + const idParts = id.split(','); |
| 67 | + const stockName = idParts[0] || ''; |
| 68 | + const market = idParts[1] || ''; |
| 69 | + const symbol = idParts[3] || ''; |
| 70 | + if (!['${MARKET_CN}', '${MARKET_HK}', '${MARKET_US}'].includes(market)) continue; |
| 71 | + const firstTd = tr.querySelector('td:first-child'); |
| 72 | + if (!firstTd) continue; |
| 73 | + const a = firstTd.querySelector('a'); |
| 74 | + if (!a) continue; |
| 75 | + let link = a.getAttribute('href') || ''; |
| 76 | + if (link.startsWith('//')) link = 'https:' + link; |
| 77 | + results.push({ stockName, market, symbol, link }); |
| 78 | + } |
| 79 | + for (const item of results) { |
| 80 | + const name = item.stockName.toLowerCase(); |
| 81 | + const key = searchKey.toLowerCase(); |
| 82 | + let hitRate = 0; |
| 83 | + if (name.includes(key)) hitRate = key.length / name.length; |
| 84 | + if (hitRate >= 0.5) matchedRes.push({ url: item.link, market: item.market, hitRate }); |
| 85 | + } |
| 86 | + matchedRes.sort((a, b) => b.hitRate - a.hitRate); |
| 87 | + if (matchedRes.length === 0) return null; |
| 88 | + if (targetMarket !== 'auto') { |
| 89 | + const candidates = matchedRes.filter(item => item.market === targetMarket); |
| 90 | + if (candidates.length === 0) return null; |
| 91 | + return candidates.reduce((best, curr) => curr.hitRate > best.hitRate ? curr : best); |
| 92 | + } |
| 93 | + const maxHitRate = Math.max(...matchedRes.map(item => item.hitRate)); |
| 94 | + const topCandidates = matchedRes.filter(item => item.hitRate === maxHitRate); |
| 95 | + for (const m of ['${MARKET_CN}', '${MARKET_HK}', '${MARKET_US}']) { |
| 96 | + const found = topCandidates.find(item => item.market === m); |
| 97 | + if (found) return found; |
| 98 | + } |
| 99 | + return topCandidates[0]; |
| 100 | + })() |
| 101 | + `); |
| 102 | + |
| 103 | + if (!suggestRes) { |
| 104 | + throw new CliError('NOT_FOUND', `No stock found for "${args.key}"`, 'Try a different name or code'); |
| 105 | + } |
| 106 | + |
| 107 | + await page.goto((suggestRes as { url: string }).url); |
| 108 | + await page.wait({ selector: '#hqDetails, .deta03, #hqPrice', timeout: 10000 }); |
| 109 | + |
| 110 | + const market = (suggestRes as { market: string }).market; |
| 111 | + let payload: unknown; |
| 112 | + |
| 113 | + if (market === MARKET_HK) { |
| 114 | + payload = await page.evaluate(` |
| 115 | + (() => { |
| 116 | + function getFieldValueFromLi(labelText) { |
| 117 | + const li = Array.from(document.querySelectorAll('.deta03 li')) |
| 118 | + .find(el => (el.textContent || '').replace(/[\\s\\uFEFF\\xA0]+$/g, '').startsWith(labelText)); |
| 119 | + return li?.querySelector('span')?.textContent?.trim() || ''; |
| 120 | + } |
| 121 | + const changeText = document.getElementById('mts_stock_hk_zdf')?.textContent || ''; |
| 122 | + const changeParts = changeText.replace(/[()()]/g, ' ').trim().split(/\\s+/); |
| 123 | + return { |
| 124 | + Symbol: document.getElementById('stock_sy')?.textContent || '', |
| 125 | + Name: document.getElementById('stock_cname')?.textContent || '', |
| 126 | + Price: document.getElementById('mts_stock_hk_price')?.textContent || '', |
| 127 | + Change: changeParts[0] || '', |
| 128 | + ChangePercent: changeParts[1] || '', |
| 129 | + Open: getFieldValueFromLi('今开盘'), |
| 130 | + High: getFieldValueFromLi('最高价'), |
| 131 | + Low: getFieldValueFromLi('最低价'), |
| 132 | + Volume: getFieldValueFromLi('成交量'), |
| 133 | + MarketCap: getFieldValueFromLi('港股市值'), |
| 134 | + }; |
| 135 | + })() |
| 136 | + `); |
| 137 | + } else if (market === MARKET_CN) { |
| 138 | + payload = await page.evaluate(` |
| 139 | + (() => { |
| 140 | + const getFieldValue = (labelText) => { |
| 141 | + const th = Array.from(document.querySelectorAll('#hqDetails th')) |
| 142 | + .find(el => el.textContent.trim().includes(labelText)); |
| 143 | + return th?.nextElementSibling?.textContent?.trim() || ''; |
| 144 | + }; |
| 145 | + return { |
| 146 | + Symbol: document.querySelector('#stockName span')?.textContent?.replace(/[()]/g, '') || '', |
| 147 | + Name: document.querySelector('#stockName i')?.textContent || '', |
| 148 | + Price: document.getElementById('price')?.textContent || '', |
| 149 | + Change: document.getElementById('change')?.textContent || '', |
| 150 | + ChangePercent: document.getElementById('changeP')?.textContent || '', |
| 151 | + Open: getFieldValue('今 开'), |
| 152 | + High: getFieldValue('最 高'), |
| 153 | + Low: getFieldValue('最 低'), |
| 154 | + Volume: getFieldValue('成交量'), |
| 155 | + MarketCap: getFieldValue('总市值'), |
| 156 | + }; |
| 157 | + })() |
| 158 | + `); |
| 159 | + } else if (market === MARKET_US) { |
| 160 | + payload = await page.evaluate(` |
| 161 | + (() => { |
| 162 | + const cleanText = (text) => text ? text.replace(/[\\t\\n\\r]/g, '').trim() : ''; |
| 163 | + const h1Text = cleanText(document.querySelector('.name h1')?.textContent || ''); |
| 164 | + const h1Parts = h1Text.split(/\\s+/); |
| 165 | + const symbolText = (h1Parts[1] || '').split(':')[1] || ''; |
| 166 | + const changeText = cleanText(document.querySelector('.hq_change')?.textContent || ''); |
| 167 | + const changeMatch = changeText.match(/([+-]?\\d+\\.?\\d*)\\(([+-]?\\d+\\.?\\d*)%\\)/); |
| 168 | + const getFieldValue = (labelText) => { |
| 169 | + const th = Array.from(document.querySelectorAll('#hqDetails th')) |
| 170 | + .find(el => el.textContent.trim().includes(labelText)); |
| 171 | + return th?.nextElementSibling?.textContent?.trim() || ''; |
| 172 | + }; |
| 173 | + const rangeText = getFieldValue('区间'); |
| 174 | + const rangeParts = rangeText.split('-'); |
| 175 | + return { |
| 176 | + Symbol: symbolText, |
| 177 | + Name: h1Parts[0] || '', |
| 178 | + Price: cleanText(document.getElementById('hqPrice')?.textContent), |
| 179 | + Change: changeMatch ? changeMatch[1] : '', |
| 180 | + ChangePercent: changeMatch ? changeMatch[2] + '%' : changeText, |
| 181 | + Open: getFieldValue('开盘'), |
| 182 | + High: rangeParts[1] ? cleanText(rangeParts[1]) : '', |
| 183 | + Low: rangeParts[0] ? cleanText(rangeParts[0]) : '', |
| 184 | + Volume: getFieldValue('成交量'), |
| 185 | + MarketCap: getFieldValue('市值'), |
| 186 | + }; |
| 187 | + })() |
| 188 | + `); |
| 189 | + } else { |
| 190 | + throw new CliError('NOT_FOUND', `Unsupported market code: ${market}`, 'Expected cn, hk, or us'); |
| 191 | + } |
| 192 | + |
| 193 | + if (!payload || typeof payload !== 'object') return []; |
| 194 | + return [payload]; |
| 195 | + }, |
| 196 | +}); |
0 commit comments