diff --git a/packages/backend/src/lib/adapters/twitter.test.ts b/packages/backend/src/lib/adapters/twitter.test.ts index cd3519f..fdf37d6 100644 --- a/packages/backend/src/lib/adapters/twitter.test.ts +++ b/packages/backend/src/lib/adapters/twitter.test.ts @@ -17,137 +17,122 @@ describe("TwitterAdapter", () => { }); describe("fetchTrending", () => { - it("returns mapped ContentItem[] from tweets format", async () => { - mockFetch.mockResolvedValueOnce({ - tweets: [ - { - id_str: "1234567890", - full_text: "This is a trending tweet!", - user: { - name: "Test User", - screen_name: "testuser", - profile_image_url_https: "https://pbs.twimg.com/avatar.jpg", - }, - favorite_count: 5000, - retweet_count: 2000, - reply_count: 300, - created_at: "Mon Jan 15 08:00:00 +0000 2024", - entities: { - hashtags: [{ text: "trending" }, { text: "test" }], - }, - }, - ], - }); - - const items = await adapter.fetchTrending(20); - - expect(items).toHaveLength(1); - expect(items[0].id).toBe("1234567890"); - expect(items[0].title).toBe("This is a trending tweet!"); - expect(items[0].platform).toBe("twitter"); - expect(items[0].author_name).toBe("Test User"); - expect(items[0].like_count).toBe(5000); - expect(items[0].share_count).toBe(2000); - expect(items[0].tags).toEqual(["trending", "test"]); - }); - - it("returns mapped ContentItem[] from GraphQL format", async () => { - mockFetch.mockResolvedValueOnce({ - timeline: { - instructions: [ - { - entries: [ - { - content: { - itemContent: { - tweet_results: { - result: { - legacy: { - id_str: "gql-001", - full_text: "GraphQL tweet", - user: { name: "GQL User" }, - favorite_count: 100, - }, - }, - }, - }, - }, - }, - ], - }, - ], - }, - }); - - const items = await adapter.fetchTrending(20); - - expect(items).toHaveLength(1); - expect(items[0].id).toBe("gql-001"); - expect(items[0].title).toBe("GraphQL tweet"); - }); - - it("returns mapped ContentItem[] from trends format", async () => { + it("fetches trending topics then searches tweets for each", async () => { + // First call: fetch_trending returns topic names mockFetch.mockResolvedValueOnce({ trends: [ + { name: "#Topic1" }, + { name: "#Topic2" }, + ], + }); + // Second call: search for #Topic1 + mockFetch.mockResolvedValueOnce({ + timeline: [ { - name: "#TrendingTopic", - tweet_volume: 50000, - url: "https://twitter.com/search?q=%23TrendingTopic", + type: "tweet", + tweet_id: "100", + text: "Tweet about Topic1", + screen_name: "user1", + favorites: 5000, + retweets: 1000, + replies: 50, + bookmarks: 20, + views: "80000", + created_at: "Mon Jan 15 08:00:00 +0000 2024", + entities: { hashtags: [{ text: "Topic1" }] }, + user_info: { name: "User One", profile_image_url_https: "https://pbs.twimg.com/a.jpg" }, + media: { photo: [{ media_url_https: "https://pbs.twimg.com/media/img.jpg" }] }, + }, + ], + }); + // Third call: search for #Topic2 + mockFetch.mockResolvedValueOnce({ + timeline: [ + { + type: "tweet", + tweet_id: "200", + text: "Tweet about Topic2", + screen_name: "user2", + favorites: 3000, + retweets: 500, + user_info: { name: "User Two" }, }, ], }); const items = await adapter.fetchTrending(20); - expect(items).toHaveLength(1); - expect(items[0].title).toBe("#TrendingTopic"); - expect(items[0].play_count).toBe(50000); - expect(items[0].author_name).toBe("Twitter Trending"); + expect(items).toHaveLength(2); + // Sorted by likes desc + expect(items[0].id).toBe("100"); + expect(items[0].title).toBe("Tweet about Topic1"); + expect(items[0].like_count).toBe(5000); + expect(items[0].share_count).toBe(1000); + expect(items[0].play_count).toBe(80000); + expect(items[0].cover_url).toBe("https://pbs.twimg.com/media/img.jpg"); + expect(items[0].author_name).toBe("User One"); + expect(items[0].tags).toEqual(["Topic1"]); + + expect(items[1].id).toBe("200"); + expect(items[1].like_count).toBe(3000); }); - it("handles empty API response", async () => { + it("deduplicates tweets across topics", async () => { + mockFetch.mockResolvedValueOnce({ + trends: [{ name: "#A" }, { name: "#B" }], + }); + mockFetch.mockResolvedValueOnce({ + timeline: [ + { type: "tweet", tweet_id: "111", text: "Same tweet", favorites: 1000, user_info: {} }, + ], + }); + mockFetch.mockResolvedValueOnce({ + timeline: [ + { type: "tweet", tweet_id: "111", text: "Same tweet", favorites: 1000, user_info: {} }, + { type: "tweet", tweet_id: "222", text: "Other tweet", favorites: 500, user_info: {} }, + ], + }); + + const items = await adapter.fetchTrending(20); + expect(items).toHaveLength(2); + expect(items[0].id).toBe("111"); + expect(items[1].id).toBe("222"); + }); + + it("handles empty trending response", async () => { mockFetch.mockResolvedValueOnce({}); const items = await adapter.fetchTrending(20); expect(items).toEqual([]); }); + it("handles search failures gracefully", async () => { + mockFetch.mockResolvedValueOnce({ + trends: [{ name: "#Fail" }], + }); + mockFetch.mockRejectedValueOnce(new Error("search failed")); + + const items = await adapter.fetchTrending(20); + expect(items).toEqual([]); + }); + it("strips HTML from tweet text", async () => { mockFetch.mockResolvedValueOnce({ - tweets: [ - { - id_str: "html-001", - full_text: "Check out this link!", - user: { name: "HTML User" }, - }, - ], + trends: [{ name: "#HTML" }], }); - - const items = await adapter.fetchTrending(20); - expect(items[0].title).toBe("Check out this link!"); - }); - - it("extracts media from extended_entities", async () => { mockFetch.mockResolvedValueOnce({ - tweets: [ + timeline: [ { - id_str: "media-001", - full_text: "Tweet with media", - user: { name: "Media User" }, - extended_entities: { - media: [ - { - media_url_https: "https://pbs.twimg.com/media/test.jpg", - video_info: { variants: [{ url: "https://video.twimg.com/test.mp4" }] }, - }, - ], - }, + type: "tweet", + tweet_id: "300", + text: "Check this!", + favorites: 100, + user_info: {}, }, ], }); const items = await adapter.fetchTrending(20); - expect(items[0].cover_url).toBe("https://pbs.twimg.com/media/test.jpg"); - expect(items[0].video_url).toBe("https://video.twimg.com/test.mp4"); + expect(items[0].title).toBe("Check this!"); }); }); diff --git a/packages/backend/src/lib/adapters/twitter.ts b/packages/backend/src/lib/adapters/twitter.ts index c9159a6..785e29c 100644 --- a/packages/backend/src/lib/adapters/twitter.ts +++ b/packages/backend/src/lib/adapters/twitter.ts @@ -13,46 +13,65 @@ function parseTwitterDate(dateStr: string): string { 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 data = await tikhubFetch( + const trendData = await tikhubFetch( "/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( + "/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, index) - ); + // 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_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";