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";