Add comprehensive news/trending feature that fetches AI-generated news headlines from Twitter's "For You" page. This feature specifically targets the AI-curated news items that Twitter displays under "Today's News" section, not generic trending hashtags. - Uses Twitter's ExplorePage GraphQL API endpoint to access the Explore page timeline data where AI headlines are located - AI headlines are identified by the explicit `is_ai_trend: true` flag in the API response - Fallback heuristic detection for full-sentence headlines (5+ words) with "News" or time indicators in social context - Headlines are found in "stories-*" modules within the initialTimeline structure under a "Today's News" header - Follows the existing mixin pattern (withNews) for composability - Supports both AI-only filtering and mixed trending/AI results - `bird news` - Fetch news and trending topics (mixed results) - `bird news --ai-only` - Fetch ONLY AI-curated headlines - `bird news -n <count>` - Limit number of results - `bird news --json` - Output as JSON - `bird news --json-full` - Include raw API response - `bird news --with-tweets` - Enrich with related tweets - `bird trending` - Alias for news command - AI headlines are clearly marked with "AI · " category prefix - Automatic deduplication of duplicate headlines - Rich formatting with category, time, post count, and URLs - src/commands/news.ts - CLI command implementation - src/lib/twitter-client-news.ts - Core news fetching functionality - tests/commands.news.test.ts - Command validation tests (7 tests) - tests/twitter-client-coverage.news.test.ts - API coverage tests (7 tests) - README.md - Added documentation for news command - src/cli/program.ts - Registered news command - src/lib/index.ts - Exported news-related types - src/lib/twitter-client.ts - Integrated withNews mixin - src/lib/query-ids.json - Added ExplorePage query ID - src/lib/twitter-client-constants.ts - Added ExplorePage constant - src/lib/twitter-client-features.ts - Added buildExploreFeatures() - scripts/update-query-ids.ts - Added ExplorePage to update script - All 268 existing tests continue to pass - Added 14 new tests covering command validation and API functionality - Tested with real Twitter data confirming AI headline detection - All TypeScript compilation and linting checks pass Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
155 lines
5.7 KiB
TypeScript
155 lines
5.7 KiB
TypeScript
import { Command } from 'commander';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import type { CliContext } from '../src/cli/shared.js';
|
|
import { registerNewsCommand } from '../src/commands/news.js';
|
|
|
|
describe('news command', () => {
|
|
let program: Command;
|
|
let mockContext: Partial<CliContext>;
|
|
|
|
beforeEach(() => {
|
|
program = new Command();
|
|
mockContext = {
|
|
resolveTimeoutFromOptions: () => 30000,
|
|
resolveQuoteDepthFromOptions: () => undefined,
|
|
resolveCredentialsFromOptions: async () => ({
|
|
cookies: {
|
|
authToken: 'auth',
|
|
ct0: 'ct0',
|
|
cookieHeader: 'auth=auth; ct0=ct0',
|
|
},
|
|
warnings: [],
|
|
}),
|
|
p: (type: string) => `[${type}] `,
|
|
colors: {
|
|
accent: (text: string) => text,
|
|
command: (text: string) => text,
|
|
muted: (text: string) => text,
|
|
section: (text: string) => text,
|
|
},
|
|
l: (key: string) => key,
|
|
};
|
|
});
|
|
|
|
it('requires positive count value', async () => {
|
|
registerNewsCommand(program, mockContext as CliContext);
|
|
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
|
throw new Error(`exit ${code}`);
|
|
}) as never);
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
|
|
try {
|
|
await expect(program.parseAsync(['node', 'bird', 'news', '--count', '0'])).rejects.toThrow('exit 1');
|
|
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--count must be a positive number'));
|
|
} finally {
|
|
exitSpy.mockRestore();
|
|
errorSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('rejects negative count value', async () => {
|
|
registerNewsCommand(program, mockContext as CliContext);
|
|
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
|
throw new Error(`exit ${code}`);
|
|
}) as never);
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
|
|
try {
|
|
await expect(program.parseAsync(['node', 'bird', 'news', '--count', '-5'])).rejects.toThrow('exit 1');
|
|
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--count must be a positive number'));
|
|
} finally {
|
|
exitSpy.mockRestore();
|
|
errorSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('rejects non-numeric count value', async () => {
|
|
registerNewsCommand(program, mockContext as CliContext);
|
|
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
|
throw new Error(`exit ${code}`);
|
|
}) as never);
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
|
|
try {
|
|
await expect(program.parseAsync(['node', 'bird', 'news', '--count', 'abc'])).rejects.toThrow('exit 1');
|
|
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--count must be a positive number'));
|
|
} finally {
|
|
exitSpy.mockRestore();
|
|
errorSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('requires positive tweets-per-item value', async () => {
|
|
registerNewsCommand(program, mockContext as CliContext);
|
|
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
|
throw new Error(`exit ${code}`);
|
|
}) as never);
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
|
|
try {
|
|
await expect(program.parseAsync(['node', 'bird', 'news', '--tweets-per-item', '0'])).rejects.toThrow('exit 1');
|
|
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--tweets-per-item must be a positive number'));
|
|
} finally {
|
|
exitSpy.mockRestore();
|
|
errorSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('rejects negative tweets-per-item value', async () => {
|
|
registerNewsCommand(program, mockContext as CliContext);
|
|
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
|
throw new Error(`exit ${code}`);
|
|
}) as never);
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
|
|
try {
|
|
await expect(program.parseAsync(['node', 'bird', 'news', '--tweets-per-item', '-3'])).rejects.toThrow('exit 1');
|
|
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--tweets-per-item must be a positive number'));
|
|
} finally {
|
|
exitSpy.mockRestore();
|
|
errorSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('rejects non-numeric tweets-per-item value', async () => {
|
|
registerNewsCommand(program, mockContext as CliContext);
|
|
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
|
throw new Error(`exit ${code}`);
|
|
}) as never);
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
|
|
try {
|
|
await expect(program.parseAsync(['node', 'bird', 'news', '--tweets-per-item', 'xyz'])).rejects.toThrow('exit 1');
|
|
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--tweets-per-item must be a positive number'));
|
|
} finally {
|
|
exitSpy.mockRestore();
|
|
errorSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('requires both authToken and ct0 credentials', async () => {
|
|
mockContext.resolveCredentialsFromOptions = async () => ({
|
|
cookies: {
|
|
authToken: '',
|
|
ct0: '',
|
|
cookieHeader: '',
|
|
},
|
|
warnings: [],
|
|
});
|
|
|
|
registerNewsCommand(program, mockContext as CliContext);
|
|
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
|
throw new Error(`exit ${code}`);
|
|
}) as never);
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
|
|
try {
|
|
await expect(program.parseAsync(['node', 'bird', 'news'])).rejects.toThrow('exit 1');
|
|
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Missing required credentials'));
|
|
} finally {
|
|
exitSpy.mockRestore();
|
|
errorSpy.mockRestore();
|
|
}
|
|
});
|
|
});
|