From 3226d8219988035ce256166be7376a54071862ab Mon Sep 17 00:00:00 2001 From: Sirius Fan <6bageya6@gmail.com> Date: Sun, 28 Jun 2026 00:50:53 +0800 Subject: [PATCH] feat(route/xueqiu): fetch user timeline via API without browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /xueqiu/user route drove a headless browser (Patchright) to load the profile page and navigate to each status for full text. The user timeline is public, so the data can be fetched directly from api.xueqiu.com once the WAF challenge cookie is obtained. - replace the browser navigation with direct ofetch calls to api.xueqiu.com/v4/statuses/user_timeline.json and statuses/show.json - requirePuppeteer: false (no longer drives a browser); antiCrawler: true - pass source=买卖 for the 交易 (type 11) tab so it filters correctly - skip the show.json detail request when legal_user_visible is true - inline images and the retweeted status into the description - derive screen_name and the avatar image from the timeline response Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/routes/xueqiu/user.ts | 226 +++++++++++++++++++------------------- 1 file changed, 110 insertions(+), 116 deletions(-) diff --git a/lib/routes/xueqiu/user.ts b/lib/routes/xueqiu/user.ts index ccda3ba3c32a..ad7d557223e0 100644 --- a/lib/routes/xueqiu/user.ts +++ b/lib/routes/xueqiu/user.ts @@ -3,10 +3,11 @@ import sanitizeHtml from 'sanitize-html'; import { parseToken } from '@/routes/xueqiu/cookies'; import type { Route } from '@/types'; import cache from '@/utils/cache'; +import ofetch from '@/utils/ofetch'; import { parseDate } from '@/utils/parse-date'; -import playwright from '@/utils/playwright'; const rootUrl = 'https://xueqiu.com'; +const apiUrl = 'https://api.xueqiu.com'; export const route: Route = { path: '/user/:id/:type?', @@ -15,8 +16,8 @@ export const route: Route = { parameters: { id: '用户 id, 可在用户主页 URL 中找到', type: '动态的类型, 不填则默认全部' }, features: { requireConfig: false, - requirePuppeteer: true, - antiCrawler: false, + requirePuppeteer: false, + antiCrawler: true, supportBT: false, supportPodcast: false, supportScihub: false, @@ -35,9 +36,59 @@ export const route: Route = { | 0 | 2 | 4 | 9 | 11 |`, }; +const stripHtml = (html: string): string => sanitizeHtml(html, { allowedTags: [], allowedAttributes: {} }); + +// Build a feed item from the timeline list data alone (no detail request). +const buildListItem = (item: any) => ({ + title: item.title || stripHtml(item.description || ''), + description: item.description || '', + pubDate: parseDate(item.created_at), + link: rootUrl + item.target, +}); + +const buildTitle = (item: any, detail: any): string => { + if (item.title) { + return item.description ? item.title + stripHtml(` | ${item.description}`) : item.title; + } + return stripHtml(item.text || item.description || detail.text || ''); +}; + +const buildDescription = (detail: any): string => { + let text = detail.text ?? detail.description; + const images = detail.image_info_list ?? []; + for (const img of images) { + if (img?.filename) { + text += `
`; + } + } + if (detail.retweeted_status) { + text += `
${detail.retweeted_status.user.screen_name}: ${detail.retweeted_status.text}
`; + } + return text; +}; + +const extractProfileImage = (user: any): string | undefined => { + if (!user?.profile_image_url || !user?.photo_domain) { + return undefined; + } + + const imageUrls = user.profile_image_url.split(',').filter(Boolean); + if (imageUrls.length === 0) { + return undefined; + } + + // Priority order for image sizes + const sizePriority = ['!180x180.png', '!50x50.png', '!30x30.png']; + const selectedImageUrl = sizePriority.map((size) => imageUrls.find((url) => url.includes(size))).find(Boolean) || imageUrls[0]; + const baseDomain = user.photo_domain.startsWith('//') ? `https:${user.photo_domain}` : user.photo_domain; + + return `${baseDomain}${selectedImageUrl}`; +}; + async function handler(ctx) { const id = ctx.req.param('id'); const type = ctx.req.param('type') || 10; + const source = type === '11' ? '买卖' : ''; const typename = { 10: '全部', 0: '原发布', @@ -48,119 +99,62 @@ async function handler(ctx) { }; const link = `${rootUrl}/u/${id}`; - const token = await parseToken(link); + const cookie = await parseToken(link); - const context = await playwright(); - try { - const mainPage = await context.newPage(); - - await mainPage.setExtraHTTPHeaders({ - Cookie: token as string, + const response = await ofetch(`${apiUrl}/v4/statuses/user_timeline.json`, { + query: { + user_id: id, + type, + source, + }, + headers: { + Cookie: cookie, Referer: link, - }); - - await mainPage.goto(link, { - waitUntil: 'domcontentloaded', - }); - await mainPage.waitForFunction(() => document.readyState === 'complete'); - - const apiUrl = `${rootUrl}/v4/statuses/user_timeline.json?user_id=${id}&type=${type}`; - const response = await mainPage.evaluate(async (url) => { - const response = await fetch(url); - return response.json(); - }, apiUrl); - - if (!response?.statuses) { - throw new Error('获取用户动态数据失败'); - } - - const data = response.statuses.filter((s) => s.mark !== 1); - - if (!data.length) { - throw new Error('未找到有效的动态数据'); - } - - const items = await Promise.all( - data.map((item) => - cache.tryGet(item.target, async () => { - const detailUrl = rootUrl + item.target; - try { - await mainPage.goto(detailUrl, { - waitUntil: 'domcontentloaded', - }); - await mainPage.waitForFunction(() => document.readyState === 'complete'); - - const content = await mainPage.evaluate(() => { - const articleContent = document.querySelector('.article__bd')?.innerHTML || ''; - const statusMatch = document.documentElement.innerHTML.match(/SNOWMAN_STATUS = (.*?\});/); - return { - articleContent, - statusData: statusMatch ? statusMatch[1] : null, - }; - }); - - if (content.statusData) { - const data = JSON.parse(content.statusData); - item.text = data.text; - } - - const retweetedStatus = item.retweeted_status ? `
${item.retweeted_status.user.screen_name}: ${item.retweeted_status.description}
` : ''; - const description = content.articleContent || item.description + retweetedStatus; - - return { - title: item.title || sanitizeHtml(description, { allowedTags: [], allowedAttributes: {} }), - description: item.text ? item.text + retweetedStatus : description, - pubDate: parseDate(item.created_at), - link: rootUrl + item.target, - }; - } catch (error: unknown) { - if (error instanceof Error && !error.message?.includes('ERR_ABORTED')) { - throw error; - } - const retweetedStatus = item.retweeted_status ? `
${item.retweeted_status.user.screen_name}: ${item.retweeted_status.description}
` : ''; - const description = item.description + retweetedStatus; - - return { - title: item.title || sanitizeHtml(description, { allowedTags: [], allowedAttributes: {} }), - description: item.description, - pubDate: parseDate(item.created_at), - link: rootUrl + item.target, - }; - } - }) - ) - ); - - const extractProfileImage = (user: any): string | undefined => { - if (!user?.profile_image_url || !user?.photo_domain) { - return undefined; - } - - const imageUrls = user.profile_image_url.split(',').filter(Boolean); - if (imageUrls.length === 0) { - return undefined; - } - - // Priority order for image sizes - const sizePriority = ['!180x180.png', '!50x50.png', '!30x30.png']; - - const selectedImageUrl = sizePriority.map((size) => imageUrls.find((url) => url.includes(size))).find(Boolean) || imageUrls[0]; - - const baseDomain = user.photo_domain.startsWith('//') ? `https:${user.photo_domain}` : user.photo_domain; - - return `${baseDomain}${selectedImageUrl}`; - }; - - const profileImage = extractProfileImage(data[0].user); - - return { - title: `${data[0].user.screen_name} 的雪球${typename[type]}动态`, - link, - description: `${data[0].user.screen_name} 的雪球${typename[type]}动态`, - image: profileImage, - item: items, - }; - } finally { - await context.close(); - } + }, + }); + + const data = response.statuses.filter((s) => s.mark !== 1); // 去除置顶动态 + + const items = await Promise.all( + data.map((item) => + cache.tryGet(item.target, async () => { + // legal_user_visible 为 true 时列表已含完整内容,无需再请求详情 + if (item.legal_user_visible) { + return buildListItem(item); + } + + try { + const detail = await ofetch(`${apiUrl}/statuses/show.json`, { + query: { + id: item.id, + }, + headers: { + Cookie: cookie, + Referer: link, + }, + }); + + return { + title: buildTitle(item, detail), + description: buildDescription(detail), + pubDate: parseDate(item.created_at), + link: rootUrl + item.target, + }; + } catch { + return buildListItem(item); + } + }) + ) + ); + + const user = data[0]?.user; + + return { + title: `${user?.screen_name ?? id} 的雪球${typename[type]}动态`, + link, + description: `${user?.screen_name ?? id} 的雪球${typename[type]}动态`, + image: extractProfileImage(user), + item: items, + allowEmpty: true, + }; }