Skip to content

Commit 6fbeda9

Browse files
dividduangdengjingrenjackwener
authored
feat: add hot stock ranking adapters for eastmoney, tdx, ths (#1025)
* feat: add hot stock ranking adapters for eastmoney, tdx, ths Add three new site adapters for Chinese stock hot rankings: - eastmoney/hot-rank: 东方财富热股榜 - tdx/hot-rank: 通达信热搜榜 - ths/hot-rank: 同花顺热股榜 All use Strategy.COOKIE browser mode with page.evaluate() DOM scraping. Each includes co-located tests (13 tests total, all passing). * fix(tdx,ths): add symbol validation and deduplication in evaluate() Add seen Set for deduplication and skip entries with empty symbol/name, matching the pattern already used in eastmoney/hot-rank.js. * fix: refine hot-rank selectors based on browser inspection - eastmoney: use table.rank_table tbody tr with td index-based extraction, fix name from a[title] to avoid post content contamination - tdx: use div.top-cell[data-code] data attributes for reliable extraction, add tags column from div.tips-item.gnbk - ths: use card-based layout selectors, remove price column (not in UI), extract tags from div.tag.PFSC-R * fix(hot-rank): align tdx and ths columns with actual output * fix: register hot stock ranking adapters --------- Co-authored-by: dengjingren <dengjingren@cn.wilmar-intl.com> Co-authored-by: jackwener <jakevingoo@gmail.com>
1 parent 9bcdaaa commit 6fbeda9

7 files changed

Lines changed: 415 additions & 0 deletions

File tree

cli-manifest.json

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5139,6 +5139,36 @@
51395139
"modulePath": "douyin/videos.js",
51405140
"sourceFile": "douyin/videos.js"
51415141
},
5142+
{
5143+
"site": "eastmoney",
5144+
"name": "hot-rank",
5145+
"description": "东方财富热股榜",
5146+
"domain": "guba.eastmoney.com",
5147+
"strategy": "cookie",
5148+
"browser": true,
5149+
"args": [
5150+
{
5151+
"name": "limit",
5152+
"type": "int",
5153+
"default": 20,
5154+
"required": false,
5155+
"help": "返回数量"
5156+
}
5157+
],
5158+
"columns": [
5159+
"rank",
5160+
"symbol",
5161+
"name",
5162+
"price",
5163+
"changePercent",
5164+
"heat",
5165+
"url"
5166+
],
5167+
"type": "js",
5168+
"modulePath": "eastmoney/hot-rank.js",
5169+
"sourceFile": "eastmoney/hot-rank.js",
5170+
"navigateBefore": true
5171+
},
51425172
{
51435173
"site": "facebook",
51445174
"name": "add-friend",
@@ -12498,6 +12528,63 @@
1249812528
"sourceFile": "taobao/search.js",
1249912529
"navigateBefore": false
1250012530
},
12531+
{
12532+
"site": "tdx",
12533+
"name": "hot-rank",
12534+
"description": "通达信热搜榜",
12535+
"domain": "pul.tdx.com.cn",
12536+
"strategy": "cookie",
12537+
"browser": true,
12538+
"args": [
12539+
{
12540+
"name": "limit",
12541+
"type": "int",
12542+
"default": 20,
12543+
"required": false,
12544+
"help": "返回数量"
12545+
}
12546+
],
12547+
"columns": [
12548+
"rank",
12549+
"symbol",
12550+
"name",
12551+
"changePercent",
12552+
"heat",
12553+
"tags"
12554+
],
12555+
"type": "js",
12556+
"modulePath": "tdx/hot-rank.js",
12557+
"sourceFile": "tdx/hot-rank.js",
12558+
"navigateBefore": true
12559+
},
12560+
{
12561+
"site": "ths",
12562+
"name": "hot-rank",
12563+
"description": "同花顺热股榜",
12564+
"domain": "eq.10jqka.com.cn",
12565+
"strategy": "cookie",
12566+
"browser": true,
12567+
"args": [
12568+
{
12569+
"name": "limit",
12570+
"type": "int",
12571+
"default": 20,
12572+
"required": false,
12573+
"help": "返回数量"
12574+
}
12575+
],
12576+
"columns": [
12577+
"rank",
12578+
"name",
12579+
"changePercent",
12580+
"heat",
12581+
"tags"
12582+
],
12583+
"type": "js",
12584+
"modulePath": "ths/hot-rank.js",
12585+
"sourceFile": "ths/hot-rank.js",
12586+
"navigateBefore": true
12587+
},
1250112588
{
1250212589
"site": "tieba",
1250312590
"name": "hot",

clis/eastmoney/hot-rank.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { cli, Strategy } from '@jackwener/opencli/registry';
2+
3+
cli({
4+
site: 'eastmoney',
5+
name: 'hot-rank',
6+
description: '东方财富热股榜',
7+
domain: 'guba.eastmoney.com',
8+
strategy: Strategy.COOKIE,
9+
navigateBefore: true,
10+
args: [
11+
{ name: 'limit', type: 'int', default: 20, help: '返回数量' },
12+
],
13+
columns: ['rank', 'symbol', 'name', 'price', 'changePercent', 'heat', 'url'],
14+
func: async (page, kwargs) => {
15+
await page.goto('https://guba.eastmoney.com/rank/');
16+
await page.wait({ selector: '#rankCont', timeout: 15000 });
17+
const data = await page.evaluate(`
18+
(() => {
19+
const cleanText = (el) => (el?.textContent || '').replace(/\\s+/g, ' ').trim();
20+
const rows = document.querySelectorAll('table.rank_table tbody tr');
21+
const results = [];
22+
const seen = new Set();
23+
let rank = 0;
24+
rows.forEach((row) => {
25+
const codeEl = row.querySelector('a.stock_code');
26+
const href = codeEl?.getAttribute('href') || '';
27+
const symbolMatch = href.match(/(\\d{6})/);
28+
if (!symbolMatch) return;
29+
const symbol = symbolMatch[1];
30+
if (seen.has(symbol)) return;
31+
seen.add(symbol);
32+
rank++;
33+
const tds = row.querySelectorAll('td');
34+
results.push({
35+
rank,
36+
symbol,
37+
name: row.querySelector('td.nametd a[title]')?.getAttribute('title') || cleanText(row.querySelector('td.nametd')),
38+
price: tds[6] ? cleanText(tds[6]) : '',
39+
changePercent: tds[8] ? cleanText(tds[8]) : '',
40+
heat: cleanText(row.querySelector('td.fans')),
41+
url: 'https://guba.eastmoney.com/list,' + symbol + '.html',
42+
});
43+
});
44+
return results;
45+
})()
46+
`);
47+
if (!Array.isArray(data)) return [];
48+
return data.slice(0, kwargs.limit);
49+
},
50+
});

clis/eastmoney/hot-rank.test.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { getRegistry } from '@jackwener/opencli/registry';
3+
import './hot-rank.js';
4+
5+
describe('eastmoney hot-rank command', () => {
6+
it('registers the command with correct metadata', () => {
7+
const command = getRegistry().get('eastmoney/hot-rank');
8+
expect(command).toBeDefined();
9+
expect(command).toMatchObject({
10+
site: 'eastmoney',
11+
name: 'hot-rank',
12+
description: expect.stringContaining('东方财富'),
13+
domain: 'guba.eastmoney.com',
14+
navigateBefore: true,
15+
});
16+
});
17+
18+
it('returns hot stock data from the page', async () => {
19+
const command = getRegistry().get('eastmoney/hot-rank');
20+
const mockData = [
21+
{ rank: 1, symbol: '600519', name: '贵州茅台', price: '1680.00', changePercent: '+2.35%', heat: '28.5万', url: 'https://guba.eastmoney.com/list,600519.html' },
22+
{ rank: 2, symbol: '000001', name: '平安银行', price: '12.50', changePercent: '-0.80%', heat: '15.2万', url: 'https://guba.eastmoney.com/list,000001.html' },
23+
];
24+
const page = {
25+
goto: vi.fn().mockResolvedValue(undefined),
26+
wait: vi.fn().mockResolvedValue(undefined),
27+
evaluate: vi.fn().mockResolvedValue(mockData),
28+
};
29+
const result = await command.func(page, { limit: 20 });
30+
expect(result).toHaveLength(2);
31+
expect(result[0]).toEqual(mockData[0]);
32+
expect(page.goto).toHaveBeenCalledWith('https://guba.eastmoney.com/rank/');
33+
});
34+
35+
it('respects the limit parameter', async () => {
36+
const command = getRegistry().get('eastmoney/hot-rank');
37+
const mockData = Array.from({ length: 30 }, (_, i) => ({
38+
rank: i + 1, symbol: `${i}`, name: `stock${i}`, price: '0', changePercent: '0%', heat: '0', url: '',
39+
}));
40+
const page = {
41+
goto: vi.fn().mockResolvedValue(undefined),
42+
wait: vi.fn().mockResolvedValue(undefined),
43+
evaluate: vi.fn().mockResolvedValue(mockData),
44+
};
45+
const result = await command.func(page, { limit: 10 });
46+
expect(result).toHaveLength(10);
47+
});
48+
49+
it('returns empty array when evaluate returns non-array', async () => {
50+
const command = getRegistry().get('eastmoney/hot-rank');
51+
const page = {
52+
goto: vi.fn().mockResolvedValue(undefined),
53+
wait: vi.fn().mockResolvedValue(undefined),
54+
evaluate: vi.fn().mockResolvedValue(null),
55+
};
56+
const result = await command.func(page, { limit: 20 });
57+
expect(result).toEqual([]);
58+
});
59+
});

clis/tdx/hot-rank.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { cli, Strategy } from '@jackwener/opencli/registry';
2+
3+
const TDX_HOT_URL = 'https://pul.tdx.com.cn/site/app/gzhbd/tdx-topsearch/page-main.html?pageName=page_topsearch&tabClickIndex=0&subtabIndex=0';
4+
5+
cli({
6+
site: 'tdx',
7+
name: 'hot-rank',
8+
description: '通达信热搜榜',
9+
domain: 'pul.tdx.com.cn',
10+
strategy: Strategy.COOKIE,
11+
navigateBefore: true,
12+
args: [
13+
{ name: 'limit', type: 'int', default: 20, help: '返回数量' },
14+
],
15+
columns: ['rank', 'symbol', 'name', 'changePercent', 'heat', 'tags'],
16+
func: async (page, kwargs) => {
17+
await page.goto(TDX_HOT_URL);
18+
await page.wait({ timeout: 15000 });
19+
const data = await page.evaluate(`
20+
(() => {
21+
const cleanText = (el) => (el?.textContent || '').replace(/\\s+/g, ' ').trim();
22+
const cells = document.querySelectorAll('div.top-cell[data-code]');
23+
const results = [];
24+
const seen = new Set();
25+
cells.forEach((cell, idx) => {
26+
const symbol = cell.getAttribute('data-code') || '';
27+
const name = cell.getAttribute('data-name') || '';
28+
if (!symbol || !name || seen.has(symbol)) return;
29+
seen.add(symbol);
30+
const tagEls = cell.querySelectorAll('div.tips-item.gnbk');
31+
const tags = Array.from(tagEls).map(t => cleanText(t)).filter(Boolean).join(',');
32+
results.push({
33+
rank: idx + 1,
34+
symbol,
35+
name,
36+
changePercent: cleanText(cell.querySelector('div.top-zf')),
37+
heat: cleanText(cell.querySelector('div.hotN')),
38+
tags,
39+
});
40+
});
41+
return results;
42+
})()
43+
`);
44+
if (!Array.isArray(data)) return [];
45+
return data.slice(0, kwargs.limit);
46+
},
47+
});

clis/tdx/hot-rank.test.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { getRegistry } from '@jackwener/opencli/registry';
3+
import './hot-rank.js';
4+
5+
describe('tdx hot-rank command', () => {
6+
it('registers the command with correct metadata', () => {
7+
const command = getRegistry().get('tdx/hot-rank');
8+
expect(command).toBeDefined();
9+
expect(command).toMatchObject({
10+
site: 'tdx',
11+
name: 'hot-rank',
12+
description: expect.stringContaining('通达信'),
13+
domain: 'pul.tdx.com.cn',
14+
navigateBefore: true,
15+
});
16+
expect(command.columns).toEqual(['rank', 'symbol', 'name', 'changePercent', 'heat', 'tags']);
17+
});
18+
19+
it('returns hot stock data from the page', async () => {
20+
const command = getRegistry().get('tdx/hot-rank');
21+
const mockData = [
22+
{ rank: 1, symbol: '600519', name: '贵州茅台', changePercent: '+2.35%', heat: '1285', tags: '白酒', },
23+
{ rank: 2, symbol: '000001', name: '平安银行', changePercent: '-0.80%', heat: '856', tags: '银行', },
24+
];
25+
const page = {
26+
goto: vi.fn().mockResolvedValue(undefined),
27+
wait: vi.fn().mockResolvedValue(undefined),
28+
evaluate: vi.fn().mockResolvedValue(mockData),
29+
};
30+
const result = await command.func(page, { limit: 20 });
31+
expect(result).toHaveLength(2);
32+
expect(result[0]).toEqual(mockData[0]);
33+
});
34+
35+
it('respects the limit parameter', async () => {
36+
const command = getRegistry().get('tdx/hot-rank');
37+
const mockData = Array.from({ length: 30 }, (_, i) => ({
38+
rank: i + 1, symbol: `${i}`, name: `stock${i}`, changePercent: '0%', heat: '0', tags: '',
39+
}));
40+
const page = {
41+
goto: vi.fn().mockResolvedValue(undefined),
42+
wait: vi.fn().mockResolvedValue(undefined),
43+
evaluate: vi.fn().mockResolvedValue(mockData),
44+
};
45+
const result = await command.func(page, { limit: 10 });
46+
expect(result).toHaveLength(10);
47+
});
48+
49+
it('returns empty array when evaluate returns non-array', async () => {
50+
const command = getRegistry().get('tdx/hot-rank');
51+
const page = {
52+
goto: vi.fn().mockResolvedValue(undefined),
53+
wait: vi.fn().mockResolvedValue(undefined),
54+
evaluate: vi.fn().mockResolvedValue(null),
55+
};
56+
const result = await command.func(page, { limit: 20 });
57+
expect(result).toEqual([]);
58+
});
59+
});

clis/ths/hot-rank.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { cli, Strategy } from '@jackwener/opencli/registry';
2+
3+
const THS_HOT_URL = 'https://eq.10jqka.com.cn/webpage/ths-hot-list/index.html?showStatusBar=true';
4+
5+
cli({
6+
site: 'ths',
7+
name: 'hot-rank',
8+
description: '同花顺热股榜',
9+
domain: 'eq.10jqka.com.cn',
10+
strategy: Strategy.COOKIE,
11+
navigateBefore: true,
12+
args: [
13+
{ name: 'limit', type: 'int', default: 20, help: '返回数量' },
14+
],
15+
columns: ['rank', 'name', 'changePercent', 'heat', 'tags'],
16+
func: async (page, kwargs) => {
17+
await page.goto(THS_HOT_URL);
18+
await page.wait({ timeout: 15000 });
19+
const data = await page.evaluate(`
20+
(() => {
21+
const cleanText = (el) => (el?.textContent || '').replace(/\\s+/g, ' ').trim();
22+
const cards = document.querySelectorAll('div.pt-22.pb-24.bgc-white.border');
23+
const results = [];
24+
const seen = new Set();
25+
cards.forEach((card, idx) => {
26+
const row = card.querySelector('div.flex.bgc-white');
27+
if (!row) return;
28+
const nameEl = row.querySelector('span.ellipsis');
29+
const name = cleanText(nameEl);
30+
if (!name || seen.has(name)) return;
31+
seen.add(name);
32+
const tagEls = card.querySelectorAll('div.tag.PFSC-R');
33+
const tags = Array.from(tagEls).map(t => cleanText(t)).filter(Boolean).join(',');
34+
const rankEl = row.querySelector('div.THSMF-M.bold');
35+
results.push({
36+
rank: cleanText(rankEl) || String(idx + 1),
37+
name,
38+
changePercent: cleanText(row.querySelector('div.range')),
39+
heat: cleanText(row.querySelector('div.col4 > span')),
40+
tags,
41+
});
42+
});
43+
return results;
44+
})()
45+
`);
46+
if (!Array.isArray(data)) return [];
47+
return data.slice(0, kwargs.limit);
48+
},
49+
});

0 commit comments

Comments
 (0)