From a510ea3f247542a33b7bc50ba51e9dc314eb2f84 Mon Sep 17 00:00:00 2001 From: sirius60111 Date: Sun, 28 Jun 2026 00:07:48 +0800 Subject: [PATCH] fix(route/xueqiu): fix stock_info failure caused by WAF challenge (#22346) The /xueqiu/stock_info route was broken by Alibaba Cloud WAF: the cookie acquisition could not pass the challenge, and the data request was sent to xueqiu.com which the WAF blocks for these endpoints. - cookies.ts: allow the `script` resource type in the page.route filter so the WAF challenge scripts can execute, enabling cookie extraction via Patchright - stock-info.ts: request data from api.xueqiu.com instead of xueqiu.com to bypass the WAF - stock-info.ts: align the supported types with the tabs on the stock page (all / discuss / trans / news / announcement). all / discuss / trans use the symbol search endpoint; news / announcement use the stock timeline endpoint. The previously broken research type is removed - stock-info.ts: fetch the stock name from the lightweight quote API since it is rendered client-side and cannot be scraped from the HTML - stock-info.ts: throw InvalidParameterError for unsupported types Co-authored-by: Claude Opus 4.8 (1M context) --- lib/routes/xueqiu/cookies.ts | 3 +- lib/routes/xueqiu/stock-info.ts | 69 ++++++++++++++++++++------------- 2 files changed, 43 insertions(+), 29 deletions(-) diff --git a/lib/routes/xueqiu/cookies.ts b/lib/routes/xueqiu/cookies.ts index dfb5c8828..509e3136b 100644 --- a/lib/routes/xueqiu/cookies.ts +++ b/lib/routes/xueqiu/cookies.ts @@ -11,7 +11,8 @@ export const parseToken = (link: string) => const page = await context.newPage(); await page.route('**/*', (route) => { const request = route.request(); - request.resourceType() === 'document' ? route.continue() : route.abort(); + const type = request.resourceType(); + (type === 'document' || type === 'script') ? route.continue() : route.abort(); }); await page.goto(link, { waitUntil: 'domcontentloaded', diff --git a/lib/routes/xueqiu/stock-info.ts b/lib/routes/xueqiu/stock-info.ts index 1ddb8e368..34e9f95d7 100644 --- a/lib/routes/xueqiu/stock-info.ts +++ b/lib/routes/xueqiu/stock-info.ts @@ -1,7 +1,7 @@ -import { load } from 'cheerio'; import queryString from 'query-string'; import sanitizeHtml from 'sanitize-html'; +import InvalidParameterError from '@/errors/types/invalid-parameter'; import { parseToken } from '@/routes/xueqiu/cookies'; import type { Route } from '@/types'; import got from '@/utils/got'; @@ -15,7 +15,7 @@ export const route: Route = { features: { requireConfig: false, requirePuppeteer: false, - antiCrawler: false, + antiCrawler: true, supportBT: false, supportPodcast: false, supportScihub: false, @@ -29,36 +29,49 @@ export const route: Route = { name: '股票信息', maintainers: ['YuYang'], handler, - description: `| 公告 | 新闻 | 研报 | -| ------------ | ---- | -------- | -| announcement | news | research |`, + description: `| 全部 | 讨论 | 交易 | 资讯 | 公告 | +| ---- | ------- | ----- | ---- | ------------ | +| all | discuss | trans | news | announcement |`, +}; + +// The two endpoints below correspond to the tabs on the stock page (xueqiu.com/S/:id). +// `source` is the API query value; `label` is the human-readable name shown in the feed title. +// `all` / `discuss` / `trans` are served by the search endpoint; `news` / `announcement` +// are served by the timeline endpoint (the search endpoint ignores these two sources). +const typeMap = { + all: { source: 'all', label: '全部', endpoint: 'search' }, + discuss: { source: 'user', label: '讨论', endpoint: 'search' }, + trans: { source: 'trans', label: '交易', endpoint: 'search' }, + news: { source: '自选股新闻', label: '资讯', endpoint: 'timeline' }, + announcement: { source: '公告', label: '公告', endpoint: 'timeline' }, }; async function handler(ctx) { const id = ctx.req.param('id'); const type = ctx.req.param('type') || 'announcement'; - const count = 10; - const page = 1; - const typename = { - announcement: '公告', - news: '自选股新闻', - research: '研报', - all: 'all', - }; - const source = typename[type]; + if (!Object.hasOwn(typeMap, type)) { + throw new InvalidParameterError(`Invalid type: ${type}. Supported types: ${Object.keys(typeMap).join(', ')}`); + } + const { source, label, endpoint } = typeMap[type]; const link = `https://xueqiu.com/S/${id}`; - const res1 = await got({ + const cookie = await parseToken(link); + + // Fetch the stock name from the lightweight quote API (the name is rendered + // client-side on the page, so it cannot be scraped from the static HTML) + const quoteRes = await got({ method: 'get', - url: link, + url: 'https://stock.xueqiu.com/v5/stock/quote.json', + searchParams: queryString.stringify({ symbol: id }), + headers: { + Cookie: cookie, + Referer: link, + }, }); + const stock_name = quoteRes.data.data?.quote?.name || id; - const token = await parseToken(link); - const $ = load(res1.data); // 使用 cheerio 加载返回的 HTML - const stock_name = $('.stock-name').text().split('(', 1)[0]; - - let query_url = 'https://xueqiu.com/statuses'; - query_url += source === 'all' ? '/search.json' : '/stock_timeline.json'; + // Use the api.xueqiu.com domain; the WAF blocks these endpoints on xueqiu.com + const query_url = endpoint === 'search' ? 'https://api.xueqiu.com/query/v1/symbol/search/status.json' : 'https://api.xueqiu.com/statuses/stock_timeline.json'; const res2 = await got({ method: 'get', @@ -67,23 +80,23 @@ async function handler(ctx) { symbol_id: id, symbol: id, source, - count, - page, - sort: 'alpha', + count: 10, + page: 1, + sort: endpoint === 'search' ? 'time' : 'alpha', comment: '0', hl: '0', }), headers: { - Cookie: token, + Cookie: cookie, Referer: link, }, }); const data = res2.data.list; return { - title: `${id} ${stock_name} - ${source}`, + title: `${id} ${stock_name} - ${label}`, link, - description: `${stock_name} - ${source}`, + description: `${stock_name} - ${label}`, item: data.map((item) => ({ title: item.title || sanitizeHtml(item.description, { allowedTags: [], allowedAttributes: {} }), description: item.description,