Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions cli-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -34192,29 +34192,29 @@
"name": "hot-rank",
"description": "同花顺热股榜",
"access": "read",
"domain": "eq.10jqka.com.cn",
"strategy": "cookie",
"browser": true,
"domain": "dq.10jqka.com.cn",
"strategy": "public",
"browser": false,
"args": [
{
"name": "limit",
"type": "int",
"default": 20,
"required": false,
"help": "返回数量"
"help": "返回数量 (1-100)"
}
],
"columns": [
"rank",
"symbol",
"name",
"changePercent",
"heat",
"tags"
],
"type": "js",
"modulePath": "ths/hot-rank.js",
"sourceFile": "ths/hot-rank.js",
"navigateBefore": true
"sourceFile": "ths/hot-rank.js"
},
{
"site": "tieba",
Expand Down
121 changes: 82 additions & 39 deletions clis/ths/hot-rank.js
Original file line number Diff line number Diff line change
@@ -1,50 +1,93 @@
import { cli, Strategy } from '@jackwener/opencli/registry';
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';

const THS_HOT_URL = 'https://eq.10jqka.com.cn/webpage/ths-hot-list/index.html?showStatusBar=true';
const THS_HOT_API = 'https://dq.10jqka.com.cn/fuyao/hot_list_data/out/hot_list/v1/stock?stock_type=a&type=hour&list_type=normal';
const THS_LIST_MAX = 100;

/**
* Format the raw heat value ("1750902.0") into the "175.1万热度" style the
* rendered page shows, so output stays compatible with the previous adapter.
*/
function formatHeat(rate) {
const n = Number(rate);
if (!Number.isFinite(n) || n <= 0) return '';
const wan = n / 10000;
// One decimal, drop the trailing ".0" for whole numbers (matches the page).
const rounded = Math.round(wan * 10) / 10;
const text = Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1);
return `${text}万热度`;
}

/** Format a percentage like -1.6667 -> "-1.67%". */
function formatPercent(value) {
if (value === null || value === undefined || value === '' || Number.isNaN(Number(value))) return '';
const n = Number(value);
const sign = n > 0 ? '+' : '';
return `${sign}${n.toFixed(2)}%`;
}

function mapRow(item) {
const concept = item?.tag?.concept_tag;
const popularity = item?.tag?.popularity_tag;
const tags = [popularity, ...(Array.isArray(concept) ? concept : [])]
.filter((t) => typeof t === 'string' && t.trim())
.join(',');
return {
rank: item?.order,
symbol: item?.code ? String(item.code) : '',
name: item?.name ?? '',
changePercent: formatPercent(item?.rise_and_fall),
heat: formatHeat(item?.rate),
tags,
};
}

cli({
site: 'ths',
name: 'hot-rank',
access: 'read',
access: 'read',
description: '同花顺热股榜',
domain: 'eq.10jqka.com.cn',
strategy: Strategy.COOKIE,
navigateBefore: true,
domain: 'dq.10jqka.com.cn',
strategy: Strategy.PUBLIC,
browser: false,
args: [
{ name: 'limit', type: 'int', default: 20, help: '返回数量' },
{ name: 'limit', type: 'int', default: 20, help: '返回数量 (1-100)' },
],
columns: ['rank', 'name', 'changePercent', 'heat', 'tags'],
func: async (page, kwargs) => {
await page.goto(THS_HOT_URL);
await page.wait({ timeout: 15000 });
const data = await page.evaluate(`
(() => {
const cleanText = (el) => (el?.textContent || '').replace(/\\s+/g, ' ').trim();
const cards = document.querySelectorAll('div.pt-22.pb-24.bgc-white.border');
const results = [];
const seen = new Set();
cards.forEach((card, idx) => {
const row = card.querySelector('div.flex.bgc-white');
if (!row) return;
const nameEl = row.querySelector('span.ellipsis');
const name = cleanText(nameEl);
if (!name || seen.has(name)) return;
seen.add(name);
const tagEls = card.querySelectorAll('div.tag.PFSC-R');
const tags = Array.from(tagEls).map(t => cleanText(t)).filter(Boolean).join(',');
const rankEl = row.querySelector('div.THSMF-M.bold');
results.push({
rank: cleanText(rankEl) || String(idx + 1),
name,
changePercent: cleanText(row.querySelector('div.range')),
heat: cleanText(row.querySelector('div.col4 > span')),
tags,
});
});
return results;
})()
`);
if (!Array.isArray(data)) return [];
return data.slice(0, kwargs.limit);
columns: ['rank', 'symbol', 'name', 'changePercent', 'heat', 'tags'],
func: async (kwargs, _debug) => {
const limit = Math.max(1, Math.min(THS_LIST_MAX, Number(kwargs?.limit) || 20));

let resp;
try {
resp = await fetch(THS_HOT_API, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
Accept: 'application/json',
Referer: 'https://eq.10jqka.com.cn/',
},
});
} catch (error) {
throw new CommandExecutionError(`ths hot-rank request failed: ${error?.message || error}`);
}
if (!resp.ok) {
throw new CommandExecutionError(`ths hot-rank failed: HTTP ${resp.status}`);
}

let payload;
try {
payload = await resp.json();
} catch (error) {
throw new CommandExecutionError(`ths hot-rank returned malformed JSON: ${error?.message || error}`);
}
if (payload?.status_code && payload.status_code !== 0) {
throw new CommandExecutionError(`ths hot-rank returned status_code=${payload.status_code}: ${payload?.status_msg || ''}`);
}

const list = Array.isArray(payload?.data?.stock_list) ? payload.data.stock_list : [];
const rows = list.map(mapRow).filter((r) => r.name).slice(0, limit);
if (rows.length === 0) {
throw new EmptyResultError('ths hot-rank', 'Upstream hot_list API returned an empty stock_list.');
}
return rows;
},
});
145 changes: 107 additions & 38 deletions clis/ths/hot-rank.test.js
Original file line number Diff line number Diff line change
@@ -1,64 +1,133 @@
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it, vi, afterEach } from 'vitest';
import { getRegistry } from '@jackwener/opencli/registry';
import { CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import './hot-rank.js';

const THS_HOT_API =
'https://dq.10jqka.com.cn/fuyao/hot_list_data/out/hot_list/v1/stock?stock_type=a&type=hour&list_type=normal';

/** Build a single stock_list item the way the upstream API does. */
function item(order, overrides = {}) {
return {
market: 17,
code: String(600000 + order),
rate: String(1000000 - order * 1000) + '.0',
rise_and_fall: -(order % 5) - 0.5,
name: `stock${order}`,
hot_rank_chg: 0,
topic: null,
tag: {
concept_tag: [`concept${order}a`, `concept${order}b`],
popularity_tag: order % 2 === 0 ? '持续上榜' : '首次上榜',
},
order,
...overrides,
};
}

function okResponse(list) {
return new Response(
JSON.stringify({ status_code: 0, status_msg: '', data: { stock_list: list } }),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}

describe('ths hot-rank command', () => {
afterEach(() => vi.unstubAllGlobals());

it('registers the command with correct metadata', () => {
const command = getRegistry().get('ths/hot-rank');
expect(command).toBeDefined();
expect(command).toMatchObject({
site: 'ths',
name: 'hot-rank',
description: expect.stringContaining('同花顺'),
domain: 'eq.10jqka.com.cn',
navigateBefore: true,
strategy: 'public',
browser: false,
});
expect(command.columns).toEqual(['rank', 'name', 'changePercent', 'heat', 'tags']);
expect(command.columns).toEqual(['rank', 'symbol', 'name', 'changePercent', 'heat', 'tags']);
});

it('includes tags column', () => {
it('hits the public hot_list API and maps fields with the authoritative rank', async () => {
const command = getRegistry().get('ths/hot-rank');
expect(command.columns).toContain('tags');
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(
okResponse([item(1, { name: '美诺华', rate: '1750902.0', rise_and_fall: -1.6667, tag: { concept_tag: ['减肥药', 'CRO概念'], popularity_tag: '2天1板' } })]),
));
vi.stubGlobal('fetch', fetchMock);

const result = await command.func({ limit: 20 });

expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toBe(THS_HOT_API);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
rank: 1,
symbol: '600001',
name: '美诺华',
changePercent: '-1.67%',
heat: '175.1万热度',
tags: '2天1板,减肥药,CRO概念',
});
});

it('returns hot stock data with tags field', async () => {
it('returns the full 100 ranks (1..100, dense) when limit=100', async () => {
const command = getRegistry().get('ths/hot-rank');
const mockData = [
{ rank: 1, name: '圣阳股份', changePercent: '+10.00%', heat: '28.5万', tags: '动力电池回收,钠离子电池' },
];
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(mockData),
};
const result = await command.func(page, { limit: 20 });
expect(result).toHaveLength(1);
expect(result[0].tags).toBe('动力电池回收,钠离子电池');
expect(result[0].name).toBe('圣阳股份');
const full = Array.from({ length: 100 }, (_, i) => item(i + 1));
vi.stubGlobal('fetch', vi.fn().mockImplementation(() => Promise.resolve(okResponse(full))));

const result = await command.func({ limit: 100 });

expect(result).toHaveLength(100);
expect(result.map((r) => r.rank)).toEqual(Array.from({ length: 100 }, (_, i) => i + 1));
});

it('respects the limit parameter', async () => {
it('clamps limit into [1, 100] and slices accordingly', async () => {
const command = getRegistry().get('ths/hot-rank');
const mockData = Array.from({ length: 30 }, (_, i) => ({
rank: i + 1, name: `stock${i}`, changePercent: '0%', heat: '0', tags: '',
}));
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(mockData),
};
const result = await command.func(page, { limit: 10 });
expect(result).toHaveLength(10);
const full = Array.from({ length: 100 }, (_, i) => item(i + 1));
vi.stubGlobal('fetch', vi.fn().mockImplementation(() => Promise.resolve(okResponse(full))));

const result = await command.func({ limit: 150 });
expect(result).toHaveLength(100);
});

it('returns empty array when evaluate returns non-array', async () => {
it('sends an authoritative rank even when upstream order is not pre-sorted', async () => {
const command = getRegistry().get('ths/hot-rank');
const page = {
goto: vi.fn().mockResolvedValue(undefined),
wait: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(null),
};
const result = await command.func(page, { limit: 20 });
expect(result).toEqual([]);
// API lists rank 3 before rank 1; we keep upstream `order` as the source of truth.
vi.stubGlobal('fetch', vi.fn().mockImplementation(() =>
Promise.resolve(okResponse([item(3), item(1), item(2)])),
));

const result = await command.func({ limit: 10 });
expect(result.map((r) => r.rank)).toEqual([3, 1, 2]);
});

it('throws CommandExecutionError on non-OK HTTP', async () => {
const command = getRegistry().get('ths/hot-rank');
vi.stubGlobal('fetch', vi.fn().mockImplementation(() => Promise.resolve(new Response('', { status: 503 }))));

await expect(command.func({ limit: 5 })).rejects.toThrow(CommandExecutionError);
});

it('throws CommandExecutionError on malformed JSON', async () => {
const command = getRegistry().get('ths/hot-rank');
vi.stubGlobal('fetch', vi.fn().mockImplementation(() => Promise.resolve(new Response('not-json{{{', { status: 200 }))));

await expect(command.func({ limit: 5 })).rejects.toThrow(CommandExecutionError);
});

it('throws CommandExecutionError on in-band error status_code', async () => {
const command = getRegistry().get('ths/hot-rank');
vi.stubGlobal('fetch', vi.fn().mockImplementation(() =>
Promise.resolve(new Response(JSON.stringify({ status_code: 500, status_msg: 'internal error' }), { status: 200 })),
));

await expect(command.func({ limit: 5 })).rejects.toThrow(CommandExecutionError);
});

it('throws EmptyResultError when stock_list is empty', async () => {
const command = getRegistry().get('ths/hot-rank');
vi.stubGlobal('fetch', vi.fn().mockImplementation(() => Promise.resolve(okResponse([]))));

await expect(command.func({ limit: 20 })).rejects.toThrow(EmptyResultError);
});
});