From 587de17223d799f300240aa07772abdd62c5c0f4 Mon Sep 17 00:00:00 2001 From: qianlei Date: Thu, 16 Jul 2026 16:41:21 +0800 Subject: [PATCH] fix(ths): rewrite hot-rank to use upstream API for full 100 ranks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ths hot-rank adapter scraped the DOM, but the page renders the list inside a Swiper carousel that lazy-loads 20 ranks per slide. Without swiping through all slides the DOM only exposes the first ~20 names, so `--limit 100` returned ~68 entries and ranks past the first slide were wrong (the printed rank is a per-slide 1-20 that recycles across slides). Switch to the public hot_list JSON API the page itself consumes. It returns all 100 entries in one call with an authoritative `order` rank and needs no auth: GET https://dq.10jqka.com.cn/fuyao/hot_list_data/out/hot_list/v1/stock ?stock_type=a&type=hour&list_type=normal - strategy PUBLIC + browser:false (pure HTTP, like toutiao/hot) - map fields: order->rank, code->symbol, rise_and_fall->changePercent, rate->heat (formatted as "x.x万热度"), tag.{popularity_tag,concept_tag}->tags - clamp limit into [1,100]; throw CommandExecutionError on HTTP/JSON/ in-band status errors and EmptyResultError on an empty stock_list - correct the non-browser func signature to (kwargs, debug); the prior (page, kwargs) signature silently swallowed --limit for browser:false commands - rebuild cli-manifest.json (new strategy/browser/columns) - add `symbol` column, aligning with eastmoney/tdx hot-rank adapters - tests now mock global fetch and cover limit=100 dense 1..100 ranks, non-sorted upstream order, clamping, and all error paths --- cli-manifest.json | 12 ++-- clis/ths/hot-rank.js | 121 +++++++++++++++++++++---------- clis/ths/hot-rank.test.js | 145 ++++++++++++++++++++++++++++---------- 3 files changed, 195 insertions(+), 83 deletions(-) diff --git a/cli-manifest.json b/cli-manifest.json index 99ae2eb56..6a67cc483 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -34192,20 +34192,21 @@ "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", @@ -34213,8 +34214,7 @@ ], "type": "js", "modulePath": "ths/hot-rank.js", - "sourceFile": "ths/hot-rank.js", - "navigateBefore": true + "sourceFile": "ths/hot-rank.js" }, { "site": "tieba", diff --git a/clis/ths/hot-rank.js b/clis/ths/hot-rank.js index d94c9fe54..cabb715e5 100644 --- a/clis/ths/hot-rank.js +++ b/clis/ths/hot-rank.js @@ -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; }, }); diff --git a/clis/ths/hot-rank.test.js b/clis/ths/hot-rank.test.js index ed39b8a64..7a3ae4615 100644 --- a/clis/ths/hot-rank.test.js +++ b/clis/ths/hot-rank.test.js @@ -1,8 +1,40 @@ -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(); @@ -10,55 +42,92 @@ describe('ths hot-rank command', () => { 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); }); });