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 { // Step 1: get trending topic names // eslint-disable-next-line @typescript-eslint/no-explicit-any const trendData = await tikhubFetch( "/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( "/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(); 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 { // eslint-disable-next-line @typescript-eslint/no-explicit-any const data = await tikhubFetch( "/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, }; } }