Skip to content

Commit 0b15561

Browse files
添加新浪财经行情及滚动新闻抓取 (#546)
* 添加新浪财经行情及滚动新闻抓取 * review: fix injection vuln, dead code, typos, hardcoded waits 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 --------- Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent f9857f8 commit 0b15561

3 files changed

Lines changed: 294 additions & 6 deletions

File tree

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
# 新浪财经 (Sina Finance)
22

3-
**Mode**: 🌐 Public · **Domain**: `finance.sina.com.cn`
3+
**Mode**: 🌐 Public / 🔐 Browser · **Domain**: `finance.sina.com.cn`
44

55
## Commands
66

7-
| Command | Description |
8-
|---------|-------------|
9-
| `opencli sinafinance news` | 新浪财经 7×24 小时实时快讯 |
7+
| Command | Description | Mode |
8+
|---------|-------------|------|
9+
| `opencli sinafinance news` | 新浪财经 7×24 小时实时快讯 | 🌐 Public |
10+
| `opencli sinafinance rolling-news` | 新浪财经滚动新闻 | 🔐 Browser |
11+
| `opencli sinafinance stock` | 新浪财经行情(A股/港股/美股) | 🔐 Browser |
1012

1113
## Usage Examples
1214

15+
### news - 7×24 实时快讯
16+
1317
```bash
1418
# Latest financial news
1519
opencli sinafinance news --limit 20
@@ -23,13 +27,59 @@ opencli sinafinance news --type 6 # 国际
2327
opencli sinafinance news -f json
2428
```
2529

26-
### Options
30+
### rolling-news - 滚动新闻
31+
32+
```bash
33+
# Rolling news feed
34+
opencli sinafinance rolling-news
35+
36+
# JSON output
37+
opencli sinafinance rolling-news -f json
38+
```
39+
40+
### stock - 股票行情
41+
42+
```bash
43+
# Search and view A-share stock
44+
opencli sinafinance stock 贵州茅台 --market cn
45+
46+
# Search and view HK stock
47+
opencli sinafinance stock 腾讯控股 --market hk
48+
49+
# Search and view US stock
50+
opencli sinafinance stock aapl --market us
51+
52+
# Auto-detect market (searches cn, hk, us in order)
53+
opencli sinafinance stock 招商证券
54+
55+
# JSON output
56+
opencli sinafinance stock 贵州茅台 -f json
57+
```
58+
59+
## Options
60+
61+
### news
2762

2863
| Option | Description |
2964
|--------|-------------|
3065
| `--limit` | Max results, up to 50 (default: 20) |
3166
| `--type` | News type: `0`=全部, `1`=A股, `2`=宏观, `3`=公司, `4`=数据, `5`=市场, `6`=国际, `7`=观点, `8`=央行, `9`=其它 |
3267

68+
### stock
69+
70+
| Option | Description |
71+
|--------|-------------|
72+
| `--key` | Stock name or code to search (required) |
73+
| `--market` | Market: `cn`, `hk`, `us`, `auto` (default: auto). When `auto`, searches in cn, hk, us order |
74+
3375
## Prerequisites
3476

35-
- No browser required — uses public API
77+
- `news`: No browser required — uses public API
78+
- `rolling-news` & `stock`: Chrome running and **logged into** `finance.sina.com.cn`
79+
- For `rolling-news` & `stock`: [Browser Bridge extension](/guide/browser-bridge) installed
80+
81+
## Notes
82+
83+
- `news` command uses a public API and does not require browser or login
84+
- `stock` command supports Chinese stock names and codes, and automatically detects the market
85+
- Market priority for auto-detection: cn (A股) → hk (港股) → us (美股)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

src/clis/sinafinance/stock.ts

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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

Comments
 (0)