feat: add multi-tab fetching to news command for 5x more headlines

Completely rewrites the news command to fetch from multiple Explore tabs
(For You, News, Sports, Entertainment) using GenericTimelineById GraphQL
API instead of just the ExplorePage initialTimeline. This delivers 15+
AI-curated headlines instead of 3, a 5x improvement in content discovery.

Key Changes:
- Add GenericTimelineById query ID support with auto-refresh capability
- Implement multi-tab fetching with cross-tab headline deduplication
- Add CLI flags for granular tab filtering (--for-you, --news-only,
  --sports, --entertainment, --trending-only)
- Fetch from 4 tabs by default (excludes trending to reduce noise)
- Add early stopping optimization when count is reached
- Handle tab-level errors gracefully without failing entire request

Implementation:
- Added TIMELINE_IDS constant with base64 timeline identifiers
- Created fetchTimelineTab() method for GenericTimelineById requests
- Created parseTimelineTabItems() for new response structure
- Removed old parseNewsItems() and extractNewsItemsFromInstructions()
- Updated all 7 tests to mock GenericTimelineById responses

API Changes:
- NewsFetchOptions: Added `tabs?: ExploreTab[]` option
- ExploreTab type exported for library consumers
- Backward compatible - existing code continues to work

Documentation:
- Added comprehensive "News & Trending" section with examples
- Updated command reference with all new flags
- Added library usage examples with tab filtering
- Updated JSON schema documentation

Testing:
- All 268 tests passing
- Real-world verified: fetches 15+ AI headlines across tabs
- Tab filtering verified: --sports, --entertainment, etc all work

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
This commit is contained in:
aavetis
2026-01-12 05:33:19 +00:00
committed by Peter Steinberger
co-authored by Claude Sonnet 4.5
parent 57d1498f1d
commit fb092f5cec
7 changed files with 344 additions and 424 deletions
+115 -227
View File
@@ -23,6 +23,33 @@ const makeResponse = (overrides: Partial<ResponseLike> = {}): 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;