fix: Twitter 适配器改用搜索端点获取热搜话题对应的真实推文

旧逻辑只返回热搜话题名称(无封面、无互动数据),现在改为:
1. 获取热搜话题列表
2. 取前 5 个话题并行搜索热门推文
3. 去重、按点赞排序后返回完整推文卡片

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
wxs
2026-03-03 16:25:08 +08:00
co-authored by Claude Opus 4.6
parent ceadeca4eb
commit 95627e3924
2 changed files with 183 additions and 154 deletions
+93 -49
View File
@@ -13,46 +13,65 @@ function parseTwitterDate(dateStr: string): string {
export class TwitterAdapter implements PlatformAdapter {
async fetchTrending(count: number): Promise<ContentItem[]> {
// Step 1: get trending topic names
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
const trendData = await tikhubFetch<any>(
"/api/v1/twitter/web/fetch_trending"
);
// Response formats:
// 1. { trends: [{ name, description, context }] } — trending topics
// 2. { tweets: [...] } — tweet objects
// 3. GraphQL timeline.instructions[].entries[]
let items: unknown[] = [];
const trends: string[] = [];
if (Array.isArray(trendData?.trends)) {
for (const t of trendData.trends) {
if (t?.name) trends.push(t.name);
}
}
if (Array.isArray(data?.trends)) {
items = data.trends;
} else if (Array.isArray(data?.tweets)) {
items = data.tweets;
} else if (data?.timeline?.instructions) {
const instructions = data.timeline.instructions;
for (const inst of instructions) {
const entries = inst?.entries || [];
for (const entry of entries) {
const tweet =
entry?.content?.itemContent?.tweet_results?.result?.legacy ||
entry?.content?.itemContent?.tweet_results?.result ||
null;
if (tweet) items.push(tweet);
if (trends.length === 0) return [];
// Step 2: search top tweets for a few trending topics in parallel
const topicsToSearch = trends.slice(0, 5);
const searchResults = await Promise.allSettled(
topicsToSearch.map((keyword) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tikhubFetch<any>(
"/api/v1/twitter/web/fetch_search_timeline",
{ keyword, search_type: "Top" }
)
)
);
// Step 3: collect tweets from all search results
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const allTweets: any[] = [];
for (const result of searchResults) {
if (result.status !== "fulfilled") continue;
const timeline = result.value?.timeline;
if (Array.isArray(timeline)) {
for (const item of timeline) {
if (item?.type === "tweet" && item?.tweet_id) {
allTweets.push(item);
}
}
}
}
return items
.slice(0, count)
.map((item: unknown, index: number) =>
this.mapToContentItem(item as Record<string, unknown>, index)
);
// Step 4: deduplicate, sort by likes, return top N
const seen = new Set<string>();
return allTweets
.map((tweet, index) => this.mapSearchTweet(tweet, index))
.filter((item) => {
if (seen.has(item.id)) return false;
seen.add(item.id);
return true;
})
.sort((a, b) => (b.like_count ?? 0) - (a.like_count ?? 0))
.slice(0, count);
}
async fetchDetail(id: string): Promise<ContentItem> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/twitter/web/fetch_tweet_detail",
"/api/v1/twitter/web/fetch_post_detail",
{ tweet_id: id }
);
@@ -69,33 +88,58 @@ export class TwitterAdapter implements PlatformAdapter {
data?.tweet?.core?.user_results?.result?.legacy ||
null;
return this.mapToContentItem(tweetData, 0, userResult);
return this.mapLegacyTweet(tweetData, 0, userResult);
}
/** Map a tweet from the search_timeline endpoint */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapToContentItem(raw: any, index: number, userOverride?: any): ContentItem {
const tweetId = raw?.id_str || raw?.rest_id || raw?.id || `tw-${index}`;
private mapSearchTweet(raw: any, index: number): ContentItem {
const tweetId = raw?.tweet_id || `tw-${index}`;
const text = stripHtml(raw?.text || "").slice(0, 200) || "Untitled";
// For trend items (from fetch_trending: { name, description, context })
if (raw?.name && !raw?.full_text && !raw?.text) {
return {
id: String(tweetId || `tw-trend-${index}`),
title: raw.name,
cover_url: undefined,
video_url: undefined,
author_name: raw?.context || "Twitter Trending",
author_avatar: undefined,
play_count: raw?.tweet_volume ?? undefined,
like_count: undefined,
collect_count: undefined,
comment_count: undefined,
share_count: undefined,
publish_time: new Date().toISOString(),
platform: "twitter",
original_url: raw?.url || `https://twitter.com/search?q=${encodeURIComponent(raw.name)}`,
tags: undefined,
};
}
const userInfo = raw?.user_info || {};
// media is { video: [...], photo: [...] } or array or null
const mediaObj = raw?.media || {};
const firstVideo = mediaObj?.video?.[0] || null;
const firstPhoto = mediaObj?.photo?.[0] || null;
const coverUrl =
firstVideo?.media_url_https ||
firstPhoto?.media_url_https ||
undefined;
const videoUrl = firstVideo?.variants?.find(
(v: { content_type?: string }) => v.content_type === "video/mp4"
)?.url || undefined;
const hashtags = raw?.entities?.hashtags;
return {
id: String(tweetId),
title: text,
cover_url: coverUrl,
video_url: videoUrl,
author_name: userInfo?.name || raw?.screen_name || "Unknown",
author_avatar: userInfo?.profile_image_url_https || undefined,
play_count: raw?.views ? parseInt(raw.views, 10) : undefined,
like_count: raw?.favorites ?? undefined,
collect_count: raw?.bookmarks ?? undefined,
comment_count: raw?.replies ?? undefined,
share_count: raw?.retweets ?? undefined,
publish_time: raw?.created_at
? parseTwitterDate(raw.created_at)
: new Date().toISOString(),
platform: "twitter",
original_url: `https://twitter.com/i/status/${tweetId}`,
tags: Array.isArray(hashtags)
? hashtags.map((h: { text?: string }) => h.text).filter(Boolean)
: undefined,
};
}
/** Map a tweet from the legacy/GraphQL detail endpoint */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapLegacyTweet(raw: any, index: number, userOverride?: any): ContentItem {
const tweetId = raw?.id_str || raw?.rest_id || raw?.id || `tw-${index}`;
const text = raw?.full_text || raw?.text || "";
const title = stripHtml(text).slice(0, 200) || "Untitled";