旧逻辑只返回热搜话题名称(无封面、无互动数据),现在改为: 1. 获取热搜话题列表 2. 取前 5 个话题并行搜索热门推文 3. 去重、按点赞排序后返回完整推文卡片 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
178 lines
6.1 KiB
TypeScript
178 lines
6.1 KiB
TypeScript
import type { ContentItem, PlatformAdapter } from "@muse/shared";
|
|
import { tikhubFetch } from "../tikhub";
|
|
|
|
function stripHtml(text: string): string {
|
|
return text.replace(/<[^>]*>/g, "").trim();
|
|
}
|
|
|
|
function parseTwitterDate(dateStr: string): string {
|
|
// Twitter format: "Mon Jan 01 00:00:00 +0000 2024"
|
|
const d = new Date(dateStr);
|
|
return isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString();
|
|
}
|
|
|
|
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 trendData = await tikhubFetch<any>(
|
|
"/api/v1/twitter/web/fetch_trending"
|
|
);
|
|
|
|
const trends: string[] = [];
|
|
if (Array.isArray(trendData?.trends)) {
|
|
for (const t of trendData.trends) {
|
|
if (t?.name) trends.push(t.name);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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_post_detail",
|
|
{ tweet_id: id }
|
|
);
|
|
|
|
// Two formats: GraphQL tweetResult.result or direct tweet object
|
|
const tweetData =
|
|
data?.tweetResult?.result?.legacy ||
|
|
data?.tweetResult?.result ||
|
|
data?.tweet?.legacy ||
|
|
data?.tweet ||
|
|
data || {};
|
|
|
|
const userResult =
|
|
data?.tweetResult?.result?.core?.user_results?.result?.legacy ||
|
|
data?.tweet?.core?.user_results?.result?.legacy ||
|
|
null;
|
|
|
|
return this.mapLegacyTweet(tweetData, 0, userResult);
|
|
}
|
|
|
|
/** Map a tweet from the search_timeline endpoint */
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
private mapSearchTweet(raw: any, index: number): ContentItem {
|
|
const tweetId = raw?.tweet_id || `tw-${index}`;
|
|
const text = stripHtml(raw?.text || "").slice(0, 200) || "Untitled";
|
|
|
|
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";
|
|
|
|
const user = userOverride || raw?.user || {};
|
|
const media =
|
|
raw?.extended_entities?.media?.[0] ||
|
|
raw?.entities?.media?.[0] ||
|
|
null;
|
|
|
|
const coverUrl = media?.media_url_https || media?.media_url || undefined;
|
|
|
|
return {
|
|
id: String(tweetId),
|
|
title,
|
|
cover_url: coverUrl,
|
|
video_url: media?.video_info?.variants?.[0]?.url || undefined,
|
|
author_name: user?.name || user?.screen_name || "Unknown",
|
|
author_avatar: user?.profile_image_url_https || undefined,
|
|
play_count: undefined,
|
|
like_count: raw?.favorite_count ?? undefined,
|
|
collect_count: raw?.bookmark_count ?? undefined,
|
|
comment_count: raw?.reply_count ?? undefined,
|
|
share_count: raw?.retweet_count ?? undefined,
|
|
publish_time: raw?.created_at
|
|
? parseTwitterDate(raw.created_at)
|
|
: new Date().toISOString(),
|
|
platform: "twitter",
|
|
original_url: `https://twitter.com/i/status/${tweetId}`,
|
|
tags: raw?.entities?.hashtags
|
|
?.map((h: { text?: string }) => h.text)
|
|
.filter(Boolean) || undefined,
|
|
};
|
|
}
|
|
}
|