Skip to content
Open
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
226 changes: 110 additions & 116 deletions lib/routes/xueqiu/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?',
Expand All @@ -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,
Expand All @@ -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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't put description into title when title is available.

}
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 += `<br><img src="https://xqimg.imedao.com/${img.filename}">`;
}
}
if (detail.retweeted_status) {
text += `<blockquote>${detail.retweeted_status.user.screen_name}:&nbsp;${detail.retweeted_status.text}</blockquote>`;
}
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: '原发布',
Expand All @@ -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 ? `<blockquote>${item.retweeted_status.user.screen_name}:&nbsp;${item.retweeted_status.description}</blockquote>` : '';
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 ? `<blockquote>${item.retweeted_status.user.screen_name}:&nbsp;${item.retweeted_status.description}</blockquote>` : '';
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,
};
}