diff --git a/README.md b/README.md index 5a0006a..ef01584 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,10 @@ bird unbookmark https://x.com/user/status/1234567890123456789 # Likes bird likes -n 5 +# News and trending topics (AI-curated from Explore tabs) +bird news --ai-only -n 10 +bird news --sports -n 5 + # Lists bird list-timeline 1234567890 -n 20 bird list-timeline https://x.com/i/lists/1234567890 --all --json @@ -82,6 +86,42 @@ bird followers --user 12345678 -n 10 # by user ID bird query-ids --fresh ``` +## News & Trending + +Fetch AI-curated news and trending topics from X's Explore page tabs: + +```bash +# Fetch 10 news items from all tabs (default: For You, News, Sports, Entertainment) +bird news -n 10 + +# Fetch only AI-curated news (filters out regular trends) +bird news --ai-only -n 20 + +# Fetch from specific tabs +bird news --news-only --ai-only -n 10 +bird news --sports -n 15 +bird news --entertainment --ai-only -n 5 + +# Include related tweets for each news item +bird news --with-tweets --tweets-per-item 3 -n 10 + +# Combine multiple tab filters +bird news --sports --entertainment -n 20 + +# JSON output +bird news --json -n 5 +bird news --json-full --ai-only -n 10 # includes raw API response +``` + +Tab options (can be combined): +- `--for-you` — Fetch from For You tab only +- `--news-only` — Fetch from News tab only +- `--sports` — Fetch from Sports tab only +- `--entertainment` — Fetch from Entertainment tab only +- `--trending-only` — Fetch from Trending tab only + +By default, the command fetches from For You, News, Sports, and Entertainment tabs (Trending excluded to reduce noise). Headlines are automatically deduplicated across tabs. + ## Library `bird` can be used as a library (same GraphQL client as the CLI): @@ -95,8 +135,15 @@ const client = new TwitterClient({ cookies }); // Search for tweets const searchResult = await client.search('from:steipete', 50); -// Fetch news and trending topics -const newsResult = await client.getNews(10, { aiOnly: true, withTweets: true }); +// Fetch news and trending topics from all tabs (default: For You, News, Sports, Entertainment) +const newsResult = await client.getNews(10, { aiOnly: true }); + +// Fetch from specific tabs with related tweets +const sportsNews = await client.getNews(10, { + aiOnly: true, + withTweets: true, + tabs: ['sports', 'entertainment'] +}); ``` ## Commands @@ -122,6 +169,14 @@ const newsResult = await client.getNews(10, { aiOnly: true, withTweets: true }); - `bird followers [--user ] [-n count] [--json]` — list users that follow you (or another user). - `bird whoami` — print which Twitter account your cookies belong to. - `bird check` — show which credentials are available and where they were sourced from. + - `bird likes [-n count] [--json]` — list your liked tweets. + - `bird news [-n count] [--ai-only] [--with-tweets] [--tweets-per-item n] [--for-you] [--news-only] [--sports] [--entertainment] [--trending-only] [--json]` — fetch news and trending topics from X's Explore tabs (fetches from For You, News, Sports, and Entertainment tabs by default). + - `bird trending` — alias for `news` command. + - `bird list-timeline [-n count] [--all] [--max-pages n] [--cursor string] [--json]` — get tweets from a list timeline; `--max-pages` implies `--all`. + - `bird following [--user ] [-n count] [--json]` — list users that you (or another user) follow. + - `bird followers [--user ] [-n count] [--json]` — list users that follow you (or another user). + - `bird whoami` — print which Twitter account your cookies belong to. + - `bird check` — show which credentials are available and where they were sourced from. Global options: - `--auth-token `: set the `auth_token` cookie manually. diff --git a/scripts/update-query-ids.ts b/scripts/update-query-ids.ts index 975d8d6..84227c4 100644 --- a/scripts/update-query-ids.ts +++ b/scripts/update-query-ids.ts @@ -21,6 +21,7 @@ const TARGET_OPERATIONS = [ 'Likes', 'ExploreSidebar', 'ExplorePage', + 'GenericTimelineById', 'TrendHistory', ] as const; diff --git a/src/commands/news.ts b/src/commands/news.ts index 7f351c0..6d7715d 100644 --- a/src/commands/news.ts +++ b/src/commands/news.ts @@ -1,7 +1,7 @@ import type { Command } from 'commander'; import type { CliContext } from '../cli/shared.js'; import { TwitterClient } from '../lib/twitter-client.js'; -import type { NewsItem } from '../lib/twitter-client-news.js'; +import type { ExploreTab, NewsItem } from '../lib/twitter-client-news.js'; function formatPostCount(count: number): string { if (count >= 1_000_000) { @@ -69,11 +69,16 @@ export function registerNewsCommand(program: Command, ctx: CliContext): void { program .command('news') .alias('trending') - .description("Fetch today's news and trending topics") + .description("Fetch AI-curated news and trending topics from Explore tabs") .option('-n, --count ', 'Number of items to fetch', '10') .option('--ai-only', 'Show only AI-curated news items') .option('--with-tweets', 'Also fetch related tweets for each news item') .option('--tweets-per-item ', 'Number of tweets to fetch per news item (default: 5)', '5') + .option('--for-you', 'Fetch only from For You tab') + .option('--news-only', 'Fetch only from News tab') + .option('--sports', 'Fetch only from Sports tab') + .option('--entertainment', 'Fetch only from Entertainment tab') + .option('--trending-only', 'Fetch only from Trending tab') .option('--json', 'Output as JSON') .option('--json-full', 'Output as JSON with full raw API response in _raw field') .action( @@ -82,6 +87,11 @@ export function registerNewsCommand(program: Command, ctx: CliContext): void { aiOnly?: boolean; withTweets?: boolean; tweetsPerItem?: string; + forYou?: boolean; + newsOnly?: boolean; + sports?: boolean; + entertainment?: boolean; + trendingOnly?: boolean; json?: boolean; jsonFull?: boolean; }) => { @@ -112,12 +122,29 @@ export function registerNewsCommand(program: Command, ctx: CliContext): void { process.exit(1); } + // Determine which tabs to fetch from + const tabs: ExploreTab[] = []; + if (cmdOpts.forYou) tabs.push('forYou'); + if (cmdOpts.newsOnly) tabs.push('news'); + if (cmdOpts.sports) tabs.push('sports'); + if (cmdOpts.entertainment) tabs.push('entertainment'); + if (cmdOpts.trendingOnly) tabs.push('trending'); + + // If no specific tabs selected, use defaults (all tabs except trending) + const tabsToFetch = tabs.length > 0 ? tabs : undefined; + const client = new TwitterClient({ cookies, timeoutMs, quoteDepth }); const includeRaw = cmdOpts.jsonFull ?? false; const withTweets = cmdOpts.withTweets ?? false; const aiOnly = cmdOpts.aiOnly ?? false; - const result = await client.getNews(count, { includeRaw, withTweets, tweetsPerItem, aiOnly }); + const result = await client.getNews(count, { + includeRaw, + withTweets, + tweetsPerItem, + aiOnly, + tabs: tabsToFetch, + }); if (result.success) { printNewsItems(result.items, ctx, { diff --git a/src/lib/index.ts b/src/lib/index.ts index eca545f..f642546 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -19,7 +19,7 @@ export { type TwitterUser, } from './twitter-client.js'; export type { HomeTimelineFetchOptions } from './twitter-client-home.js'; -export type { NewsFetchOptions, NewsItem, NewsResult } from './twitter-client-news.js'; +export type { ExploreTab, NewsFetchOptions, NewsItem, NewsResult } from './twitter-client-news.js'; export type { SearchFetchOptions } from './twitter-client-search.js'; export type { TimelineFetchOptions } from './twitter-client-timelines.js'; export type { TweetFetchOptions } from './twitter-client-tweet-detail.js'; diff --git a/src/lib/query-ids.json b/src/lib/query-ids.json index d0823b6..2214567 100644 --- a/src/lib/query-ids.json +++ b/src/lib/query-ids.json @@ -12,5 +12,6 @@ "Likes": "ETJflBunfqNa1uE1mBPCaw", "ExploreSidebar": "lpSN4M6qpimkF4nRFPE3nQ", "ExplorePage": "kheAINB_4pzRDqkzG3K-ng", + "GenericTimelineById": "uGSr7alSjR9v6QJAIaqSKQ", "TrendHistory": "Sj4T-jSB9pr0Mxtsc1UKZQ" } diff --git a/src/lib/twitter-client-news.ts b/src/lib/twitter-client-news.ts index 3697c75..9a22858 100644 --- a/src/lib/twitter-client-news.ts +++ b/src/lib/twitter-client-news.ts @@ -6,6 +6,17 @@ import type { SearchResult, TweetData } from './twitter-client-types.js'; const POST_COUNT_REGEX = /[\d.]+[KMB]?\s*posts?/i; const POST_COUNT_MATCH_REGEX = /([\d.]+)([KMB]?)\s*posts?/i; +// Timeline IDs for different Explore tabs +const TIMELINE_IDS = { + forYou: 'VGltZWxpbmU6DAC2CwABAAAAB2Zvcl95b3UAAA==', + trending: 'VGltZWxpbmU6DAC2CwABAAAACHRyZW5kaW5nAAA=', + news: 'VGltZWxpbmU6DAC2CwABAAAABG5ld3MAAA==', + sports: 'VGltZWxpbmU6DAC2CwABAAAABnNwb3J0cwAA', + entertainment: 'VGltZWxpbmU6DAC2CwABAAAADWVudGVydGFpbm1lbnQAAA==', +} as const; + +export type ExploreTab = keyof typeof TIMELINE_IDS; + /** Options for news fetch methods */ export interface NewsFetchOptions { /** Include raw GraphQL response in `_raw` field */ @@ -16,6 +27,8 @@ export interface NewsFetchOptions { tweetsPerItem?: number; /** Filter to show only AI-curated news items */ aiOnly?: boolean; + /** Fetch from specific tabs only (default: all tabs) */ + tabs?: ExploreTab[]; } export interface NewsItem { @@ -55,29 +68,94 @@ export function withNews>( } /** - * Fetch news and trending topics from Twitter's Explore page + * Fetch news and trending topics from Twitter's Explore page tabs */ async getNews(count = 10, options: NewsFetchOptions = {}): Promise { - const { includeRaw = false, withTweets = false, tweetsPerItem = 5, aiOnly = false } = options; + const { + includeRaw = false, + withTweets = false, + tweetsPerItem = 5, + aiOnly = false, + tabs = ['forYou', 'news', 'sports', 'entertainment'], + } = options; const debug = process.env.BIRD_DEBUG === '1'; - // Try ExplorePage first - this has AI headlines in initialTimeline if (debug) { - console.error('[getNews] Fetching from ExplorePage (has AI headlines)...'); + console.error(`[getNews] Fetching from tabs: ${tabs.join(', ')}`); } - const queryId = await this.getQueryId('ExplorePage'); + + const allItems: NewsItem[] = []; + const seenHeadlines = new Set(); + + // Fetch from each tab + for (const tab of tabs) { + const timelineId = TIMELINE_IDS[tab]; + if (!timelineId) { + continue; + } + + try { + const tabItems = await this.fetchTimelineTab(tab, timelineId, count, aiOnly, includeRaw, debug); + + // Deduplicate across tabs + for (const item of tabItems) { + if (!seenHeadlines.has(item.headline)) { + seenHeadlines.add(item.headline); + allItems.push(item); + } + } + + if (debug) { + console.error( + `[getNews] Tab ${tab}: found ${tabItems.length} items, total unique: ${allItems.length}`, + ); + } + + // Stop early if we have enough + if (allItems.length >= count) { + break; + } + } catch (error) { + if (debug) { + console.error(`[getNews] Error fetching tab ${tab}:`, error); + } + // Continue with other tabs + } + } + + if (allItems.length === 0) { + return { success: false, error: 'No news items found' }; + } + + // Limit to requested count + const items = allItems.slice(0, count); + + if (withTweets) { + await this.enrichWithTweets(items, tweetsPerItem, includeRaw); + } + + return { success: true, items }; + } + + /** + * Fetch a specific timeline tab using GenericTimelineById + */ + private async fetchTimelineTab( + tabName: string, + timelineId: string, + maxCount: number, + aiOnly: boolean, + includeRaw: boolean, + debug: boolean, + ): Promise { + const queryId = await this.getQueryId('GenericTimelineById'); const features = buildExploreFeatures(); const variables = { - includePromotedContent: true, - withBirdwatchNotes: false, - withCommunity: true, - withSuperFollowsUserFields: true, - withDownvotePerspective: false, - withReactionsMetadata: false, - withReactionsPerspective: false, - withSuperFollowsTweetFields: true, + timelineId: timelineId, + count: maxCount * 2, // Fetch more to account for filtering + includePromotedContent: false, }; const params = new URLSearchParams({ @@ -85,196 +163,81 @@ export function withNews>( features: JSON.stringify(features), }); - const url = `${TWITTER_API_BASE}/${queryId}/ExplorePage?${params.toString()}`; + const url = `${TWITTER_API_BASE}/${queryId}/GenericTimelineById?${params.toString()}`; - try { - const response = await this.fetchWithTimeout(url, { - method: 'GET', - headers: this.getHeaders(), - }); + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getHeaders(), + }); - if (!response.ok) { - const text = await response.text(); - return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` }; - } - - const data = (await response.json()) as { - // biome-ignore lint/suspicious/noExplicitAny: API response structure is complex - data?: any; - // biome-ignore lint/suspicious/noExplicitAny: API errors can have any structure - errors?: Array<{ message: string; code?: number; [key: string]: any }>; - }; - - // Debug: save response if BIRD_DEBUG_JSON is set - if (process.env.BIRD_DEBUG_JSON) { - const fs = await import('node:fs/promises'); - const debugPath = process.env.BIRD_DEBUG_JSON.replace('.json', '-explorepage.json'); - await fs.writeFile(debugPath, JSON.stringify(data, null, 2)).catch(() => {}); - if (debug) { - console.error(`[ExplorePage] Saved response to ${debugPath}`); - } - } - - if (data.errors && data.errors.length > 0) { - return { success: false, error: data.errors.map((e) => e.message).join('; ') }; - } - - const items = this.parseNewsItems(data, count, aiOnly, includeRaw); - - if (items.length === 0) { - return { success: false, error: 'No news items found' }; - } - - if (withTweets) { - await this.enrichWithTweets(items, tweetsPerItem, includeRaw); - } - - return { success: true, items }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - return { success: false, error: `Failed to fetch news: ${errorMessage}` }; + if (!response.ok) { + const text = await response.text(); + throw new Error(`HTTP ${response.status}: ${text.slice(0, 200)}`); } + + const data = (await response.json()) as { + // biome-ignore lint/suspicious/noExplicitAny: API response structure is complex + data?: any; + // biome-ignore lint/suspicious/noExplicitAny: API errors can have any structure + errors?: Array<{ message: string; code?: number; [key: string]: any }>; + }; + + // Debug: save response if BIRD_DEBUG_JSON is set + if (process.env.BIRD_DEBUG_JSON) { + const fs = await import('node:fs/promises'); + const debugPath = process.env.BIRD_DEBUG_JSON.replace('.json', `-${tabName}.json`); + await fs.writeFile(debugPath, JSON.stringify(data, null, 2)).catch(() => {}); + } + + if (data.errors && data.errors.length > 0) { + throw new Error(data.errors.map((e) => e.message).join('; ')); + } + + // Parse timeline response + return this.parseTimelineTabItems(data, tabName, maxCount, aiOnly, includeRaw); } - // biome-ignore lint/suspicious/noExplicitAny: API response structure is complex - private parseNewsItems(data: any, maxCount: number, aiOnly: boolean, includeRaw: boolean): NewsItem[] { - const allItems: NewsItem[] = []; - const seenHeadlines = new Set(); - const debug = process.env.BIRD_DEBUG === '1'; - - if (debug) { - console.error('[ExplorePage] Processing explore_page data...'); - } - - if (!data.data?.explore_page) { - return []; - } - - const explorePage = data.data.explore_page; - - if (debug) { - console.error('[ExplorePage] Available sections:', Object.keys(explorePage.body || {})); - } - - const body = explorePage.body || {}; - - // Check the timelines array first (For You, News tabs, etc.) - // biome-ignore lint/suspicious/noExplicitAny: exploring API structure - const timelines = (body as any).timelines || []; - if (debug) { - console.error(`[ExplorePage] Found ${timelines.length} timelines`); - } - - for (const timelineObj of timelines) { - if (debug) { - console.error(`[ExplorePage] Timeline ID: ${timelineObj.id}, Label: ${timelineObj.labelText}`); - console.error(`[ExplorePage] Timeline keys:`, Object.keys(timelineObj)); - } - - const timeline = timelineObj.timeline; - if (timeline) { - const instructions = timeline.timeline?.instructions ?? timeline.instructions ?? []; - - if (debug) { - console.error(`[ExplorePage] Timeline ${timelineObj.labelText} has ${instructions.length} instructions`); - } - - const itemsFromTimeline = this.extractNewsItemsFromInstructions( - instructions, - timelineObj.__typename || 'timeline', - seenHeadlines, - maxCount, - aiOnly, - includeRaw, - ); - - if (debug) { - console.error(`[ExplorePage] Timeline found ${itemsFromTimeline.length} AI news items`); - } - - allItems.push(...itemsFromTimeline); - - if (allItems.length >= maxCount) { - break; - } - } - } - - // Also check initialTimeline as fallback - if (allItems.length < maxCount && body.initialTimeline) { - const timeline = body.initialTimeline.timeline; - if (timeline) { - const instructions = timeline.timeline?.instructions ?? []; - - const itemsFromInitial = this.extractNewsItemsFromInstructions( - instructions, - 'initialTimeline', - seenHeadlines, - maxCount - allItems.length, - aiOnly, - includeRaw, - ); - - allItems.push(...itemsFromInitial); - } - } - - return allItems; - } - - private extractNewsItemsFromInstructions( + /** + * Parse items from a GenericTimelineById response + */ + private parseTimelineTabItems( // biome-ignore lint/suspicious/noExplicitAny: API response structure is complex - instructions: any[], + data: any, source: string, - seenHeadlines: Set, maxCount: number, aiOnly: boolean, includeRaw: boolean, ): NewsItem[] { const items: NewsItem[] = []; - const debug = process.env.BIRD_DEBUG === '1'; + const seenHeadlines = new Set(); + + // Navigate to timeline instructions + const timeline = data?.data?.timeline?.timeline; + if (!timeline) { + return []; + } + + const instructions = timeline.instructions || []; for (const instruction of instructions) { if (instruction.type !== 'TimelineAddEntries') { continue; } - const entries = instruction.entries ?? []; - - if (debug) { - console.error(`[${source}] Processing ${entries.length} entries`); - } + const entries = instruction.entries || []; for (const entry of entries) { if (items.length >= maxCount) { break; } - if (debug) { - console.error( - `[${source}] Entry ID: ${entry.entryId}, content type: ${entry.content?.__typename || entry.content?.entryType || 'unknown'}`, - ); - - // Check if this is a "Today's News" or news section header - if (entry.content?.header || entry.content?.displayType === 'VerticalConversation') { - console.error(`[${source}] Found potential news section:`, entry.content?.header); - } - } - const content = entry.content; if (!content) { continue; } // Handle TimelineTimelineItem (single trend item) - if (content.itemContent && items.length < maxCount) { - if (debug && content.itemContent.is_ai_trend) { - console.error( - `[ExplorePage] Found AI trend in ${entry.entryId}:`, - JSON.stringify(content.itemContent, null, 2).substring(0, 500), - ); - } - + if (content.itemContent) { const newsItem = this.parseNewsItemFromContent( content.itemContent, entry.entryId, @@ -290,11 +253,7 @@ export function withNews>( } // Handle TimelineTimelineModule (multiple items) - const itemsArray = content?.items ?? []; - - if (debug && itemsArray.length > 0) { - console.error(`[${source}] Module has ${itemsArray.length} items`); - } + const itemsArray = content?.items || []; for (const data of itemsArray) { if (items.length >= maxCount) { @@ -307,19 +266,6 @@ export function withNews>( continue; } - if (debug) { - console.error( - `[${source}] Module item type: ${itemContent.__typename}, name: ${itemContent.name}, is_ai: ${itemContent.is_ai_trend}`, - ); - } - - if (debug && itemContent.is_ai_trend) { - console.error( - `[ExplorePage] Found AI trend in module ${entry.entryId}:`, - JSON.stringify(itemContent, null, 2).substring(0, 500), - ); - } - const newsItem = this.parseNewsItemFromContent( itemContent, entry.entryId, @@ -339,6 +285,8 @@ export function withNews>( return items; } + // biome-ignore lint/suspicious/noExplicitAny: API response structure is complex + private parseNewsItemFromContent( // biome-ignore lint/suspicious/noExplicitAny: API response structure is complex itemContent: any, diff --git a/tests/twitter-client-coverage.news.test.ts b/tests/twitter-client-coverage.news.test.ts index 93fc0e1..4c6867c 100644 --- a/tests/twitter-client-coverage.news.test.ts +++ b/tests/twitter-client-coverage.news.test.ts @@ -23,6 +23,33 @@ const makeResponse = (overrides: Partial = {}): ResponseLike => ({ ...overrides, }); +// Helper to create GenericTimelineById response structure +const makeTimelineResponse = (items: any[]) => ({ + data: { + timeline: { + timeline: { + instructions: [ + { + type: 'TimelineAddEntries', + entries: items.map((item, index) => ({ + entryId: `test-entry-${index}`, + content: { + items: [ + { + item: { + itemContent: item, + }, + }, + ], + }, + })), + }, + ], + }, + }, + }, +}); + describe('TwitterClient news API coverage', () => { const originalFetch = global.fetch; @@ -32,51 +59,28 @@ describe('TwitterClient news API coverage', () => { }); describe('getNews', () => { - it('returns news items from ExplorePage', async () => { - const mockFetch = vi.fn().mockResolvedValueOnce( - makeResponse({ - json: async () => ({ - data: { - explore_page: { - body: { - initialTimeline: { - timeline: { - timeline: { - instructions: [ - { - type: 'TimelineAddEntries', - entries: [ - { - entryId: 'test-entry-1', - content: { - items: [ - { - itemContent: { - is_ai_trend: true, - name: 'AI Breakthrough in Machine Learning', - social_context: { - text: 'AI · 2h ago · 15.5K posts', - }, - trend_url: { - url: 'https://x.com/hashtag/AI', - }, - }, - }, - ], - }, - }, - ], - }, - ], - }, - }, + it('returns news items from timeline tabs', async () => { + // Mock multiple tab requests (forYou, news, sports, entertainment) + const mockFetch = vi + .fn() + .mockResolvedValueOnce( + makeResponse({ + json: async () => + makeTimelineResponse([ + { + is_ai_trend: true, + name: 'AI Breakthrough in Machine Learning', + social_context: { + text: 'AI · 2h ago · 15.5K posts', + }, + trend_url: { + url: 'https://x.com/hashtag/AI', }, }, - }, - }, + ]), }), - }), - ); + ) + .mockResolvedValue(makeResponse({ json: async () => makeTimelineResponse([]) })); global.fetch = mockFetch as unknown as typeof fetch; @@ -94,57 +98,24 @@ describe('TwitterClient news API coverage', () => { }); it('filters to AI-only items when aiOnly is true', async () => { - const mockFetch = vi.fn().mockResolvedValueOnce( - makeResponse({ - json: async () => ({ - data: { - explore_page: { - body: { - initialTimeline: { - timeline: { - timeline: { - instructions: [ - { - type: 'TimelineAddEntries', - entries: [ - { - entryId: 'test-entry-1', - content: { - items: [ - { - itemContent: { - is_ai_trend: true, - name: 'AI News', - }, - }, - ], - }, - }, - { - entryId: 'test-entry-2', - content: { - items: [ - { - itemContent: { - is_ai_trend: false, - name: 'Regular News', - }, - }, - ], - }, - }, - ], - }, - ], - }, - }, - }, + const mockFetch = vi + .fn() + .mockResolvedValueOnce( + makeResponse({ + json: async () => + makeTimelineResponse([ + { + is_ai_trend: true, + name: 'AI News', }, - }, - }, + { + is_ai_trend: false, + name: 'Regular News', + }, + ]), }), - }), - ); + ) + .mockResolvedValue(makeResponse({ json: async () => makeTimelineResponse([]) })); global.fetch = mockFetch as unknown as typeof fetch; @@ -157,9 +128,10 @@ describe('TwitterClient news API coverage', () => { }); it('returns error for non-ok responses', async () => { + // Mock all 4 default tabs (forYou, news, sports, entertainment) to return HTTP 500 const mockFetch = vi .fn() - .mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'Server error' })); + .mockResolvedValue(makeResponse({ ok: false, status: 500, text: async () => 'Server error' })); global.fetch = mockFetch as unknown as typeof fetch; @@ -167,11 +139,12 @@ describe('TwitterClient news API coverage', () => { const result = await client.getNews(10); expect(result.success).toBe(false); - expect(result.error).toContain('HTTP 500'); + expect(result.error).toContain('No news items found'); }); it('returns error when API returns errors', async () => { - const mockFetch = vi.fn().mockResolvedValueOnce( + // Mock all 4 default tabs to return API errors + const mockFetch = vi.fn().mockResolvedValue( makeResponse({ json: async () => ({ errors: [{ message: 'Rate limited' }, { message: 'Too many requests' }], @@ -185,30 +158,13 @@ describe('TwitterClient news API coverage', () => { const result = await client.getNews(10); expect(result.success).toBe(false); - expect(result.error).toContain('Rate limited'); - expect(result.error).toContain('Too many requests'); + expect(result.error).toContain('No news items found'); }); it('returns error when no news items found', async () => { - const mockFetch = vi.fn().mockResolvedValueOnce( - makeResponse({ - json: async () => ({ - data: { - explore_page: { - body: { - initialTimeline: { - timeline: { - timeline: { - instructions: [], - }, - }, - }, - }, - }, - }, - }), - }), - ); + const mockFetch = vi + .fn() + .mockResolvedValue(makeResponse({ json: async () => makeTimelineResponse([]) })); global.fetch = mockFetch as unknown as typeof fetch; @@ -219,58 +175,32 @@ describe('TwitterClient news API coverage', () => { expect(result.error).toContain('No news items found'); }); - it('deduplicates headlines', async () => { - const mockFetch = vi.fn().mockResolvedValueOnce( - makeResponse({ - json: async () => ({ - data: { - explore_page: { - body: { - initialTimeline: { - timeline: { - timeline: { - instructions: [ - { - type: 'TimelineAddEntries', - entries: [ - { - entryId: 'test-entry-1', - content: { - items: [ - { - itemContent: { - is_ai_trend: true, - name: 'Duplicate Headline', - }, - }, - ], - }, - }, - { - entryId: 'test-entry-2', - content: { - items: [ - { - itemContent: { - is_ai_trend: true, - name: 'Duplicate Headline', - }, - }, - ], - }, - }, - ], - }, - ], - }, - }, - }, + it('deduplicates headlines across tabs', async () => { + const mockFetch = vi + .fn() + .mockResolvedValueOnce( + makeResponse({ + json: async () => + makeTimelineResponse([ + { + is_ai_trend: true, + name: 'Duplicate Headline', }, - }, - }, + ]), }), - }), - ); + ) + .mockResolvedValueOnce( + makeResponse({ + json: async () => + makeTimelineResponse([ + { + is_ai_trend: true, + name: 'Duplicate Headline', + }, + ]), + }), + ) + .mockResolvedValue(makeResponse({ json: async () => makeTimelineResponse([]) })); global.fetch = mockFetch as unknown as typeof fetch; @@ -283,70 +213,28 @@ describe('TwitterClient news API coverage', () => { }); it('respects count parameter', async () => { - const mockFetch = vi.fn().mockResolvedValueOnce( - makeResponse({ - json: async () => ({ - data: { - explore_page: { - body: { - initialTimeline: { - timeline: { - timeline: { - instructions: [ - { - type: 'TimelineAddEntries', - entries: [ - { - entryId: 'test-entry-1', - content: { - items: [ - { - itemContent: { - is_ai_trend: true, - name: 'News 1', - }, - }, - ], - }, - }, - { - entryId: 'test-entry-2', - content: { - items: [ - { - itemContent: { - is_ai_trend: true, - name: 'News 2', - }, - }, - ], - }, - }, - { - entryId: 'test-entry-3', - content: { - items: [ - { - itemContent: { - is_ai_trend: true, - name: 'News 3', - }, - }, - ], - }, - }, - ], - }, - ], - }, - }, - }, + const mockFetch = vi + .fn() + .mockResolvedValueOnce( + makeResponse({ + json: async () => + makeTimelineResponse([ + { + is_ai_trend: true, + name: 'News 1', }, - }, - }, + { + is_ai_trend: true, + name: 'News 2', + }, + { + is_ai_trend: true, + name: 'News 3', + }, + ]), }), - }), - ); + ) + .mockResolvedValue(makeResponse({ json: async () => makeTimelineResponse([]) })); global.fetch = mockFetch as unknown as typeof fetch;