From 81921226f099e7f7a2107be15fc9783218a6f400 Mon Sep 17 00:00:00 2001 From: yutakobayashidev Date: Mon, 13 Jul 2026 09:18:42 +0900 Subject: [PATCH] feat: support ranked tweet search --- CHANGELOG.md | 6 ++++ README.md | 7 ++++- src/commands/search.ts | 14 ++++++++-- src/lib/index.ts | 6 +++- src/lib/twitter-client-search.ts | 8 ++++-- tests/commands.search.test.ts | 28 +++++++++++++++++-- tests/library-exports.test.ts | 9 +++++- tests/twitter-client.search-bookmarks.test.ts | 14 +++++++--- 8 files changed, 78 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1db70cd..5669e33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Added + +- Add Top and Latest product selection to library and CLI search. + ## 0.9.0 — 2026-06-26 ### Added diff --git a/README.md b/README.md index bb43f21..d2da8ab 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ bird thread 1234567890123456789 --max-pages 3 --json # Search + mentions bird search "from:steipete" -n 5 +bird search "AI lang:ja" --product Top -n 20 +bird search "from:steipete" --product Latest -n 20 bird mentions -n 5 bird mentions --user @steipete -n 5 @@ -145,6 +147,9 @@ const client = new TwitterClient({ // Search for tweets const searchResult = await client.search('from:steipete', 50); +const popular = await client.search('AI lang:ja', 20, { + product: 'Top', +}); // Fetch news and trending topics from all tabs (default: For You, News, Sports, Entertainment) const newsResult = await client.getNews(10, { aiOnly: true }); @@ -184,7 +189,7 @@ Fields: - `bird [--json]` — shorthand for `read` when only a URL or ID is provided. - `bird replies [--all] [--max-pages n] [--cursor string] [--delay ms] [--json]` — list replies to a tweet. - `bird thread [--all] [--max-pages n] [--cursor string] [--delay ms] [--json]` — show the full conversation thread. -- `bird search "" [-n count] [--all] [--max-pages n] [--cursor string] [--json]` — search for tweets matching a query; `--max-pages` requires `--all` or `--cursor`. +- `bird search "" [-n count] [--all] [--max-pages n] [--cursor string] [--product Top|Latest] [--json]` — search for tweets matching a query; `Latest` is the default product, and `--max-pages` requires `--all` or `--cursor`. - `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user). - `bird user-tweets <@handle> [-n count] [--cursor string] [--max-pages n] [--delay ms] [--json]` — get tweets from a user's profile timeline. - `bird bookmarks [-n count] [--folder-id id] [--all] [--max-pages n] [--cursor string] [--expand-root-only] [--author-chain] [--author-only] [--full-chain-only] [--include-ancestor-branches] [--include-parent] [--thread-meta] [--sort-chronological] [--json]` — list your bookmarked tweets (or a specific bookmark folder); expansion flags control thread context; `--max-pages` requires `--all` or `--cursor`. diff --git a/src/commands/search.ts b/src/commands/search.ts index 909e437..e65a3a7 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -1,8 +1,9 @@ -import type { Command } from 'commander'; +import { type Command, Option } from 'commander'; import { parsePaginationFlags } from '../cli/pagination.js'; import type { CliContext } from '../cli/shared.js'; import { mentionsQueryFromUserOption, normalizeHandle } from '../lib/normalize-handle.js'; import { TwitterClient } from '../lib/twitter-client.js'; +import type { SearchProduct } from '../lib/twitter-client-search.js'; export function registerSearchCommands(program: Command, ctx: CliContext): void { program @@ -13,6 +14,11 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void .option('--all', 'Fetch all search results (paged)') .option('--max-pages ', 'Stop after N pages when using --all') .option('--cursor ', 'Resume pagination from a cursor') + .addOption( + new Option('--product ', 'Search product') + .choices(['Top', 'Latest'] satisfies SearchProduct[]) + .default('Latest'), + ) .option('--json', 'Output as JSON') .option('--json-full', 'Output as JSON with full raw API response in _raw field') .action( @@ -23,6 +29,7 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void all?: boolean; maxPages?: string; cursor?: string; + product?: SearchProduct; json?: boolean; jsonFull?: boolean; }, @@ -53,8 +60,9 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth }); const includeRaw = cmdOpts.jsonFull ?? false; - const searchOptions = { includeRaw }; - const paginationOptions = { includeRaw, maxPages, cursor: pagination.cursor }; + const product = cmdOpts.product ?? 'Latest'; + const searchOptions = { includeRaw, product }; + const paginationOptions = { includeRaw, maxPages, cursor: pagination.cursor, product }; const result = usePagination ? await client.getAllSearchResults(query, paginationOptions) : await client.search(query, count, searchOptions); diff --git a/src/lib/index.ts b/src/lib/index.ts index 95608d2..e5daf66 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -11,7 +11,11 @@ export { } from './twitter-client.js'; export type { HomeTimelineFetchOptions } from './twitter-client-home.js'; export type { ExploreTab, NewsFetchOptions, NewsItem, NewsResult } from './twitter-client-news.js'; -export type { SearchFetchOptions } from './twitter-client-search.js'; +export type { + SearchFetchOptions, + SearchPaginationOptions, + SearchProduct, +} from './twitter-client-search.js'; export type { TimelineFetchOptions } from './twitter-client-timelines.js'; export type { TweetFetchOptions } from './twitter-client-tweet-detail.js'; export type { diff --git a/src/lib/twitter-client-search.ts b/src/lib/twitter-client-search.ts index d096643..1e5657d 100644 --- a/src/lib/twitter-client-search.ts +++ b/src/lib/twitter-client-search.ts @@ -6,10 +6,14 @@ import { extractCursorFromInstructions, parseTweetsFromInstructions } from './tw const RAW_QUERY_MISSING_REGEX = /must be defined/i; +export type SearchProduct = 'Top' | 'Latest'; + /** Options for search methods */ export interface SearchFetchOptions { /** Include raw GraphQL response in `_raw` field */ includeRaw?: boolean; + /** Search result ranking (default: Latest) */ + product?: SearchProduct; } /** Options for paged search methods */ @@ -80,7 +84,7 @@ export function withSearch> let cursor: string | undefined = options.cursor; let nextCursor: string | undefined; let pagesFetched = 0; - const { includeRaw = false, maxPages } = options; + const { includeRaw = false, maxPages, product = 'Latest' } = options; const fetchPage = async (pageCount: number, pageCursor?: string) => { let lastError: string | undefined; @@ -92,7 +96,7 @@ export function withSearch> rawQuery: query, count: pageCount, querySource: 'typed_query', - product: 'Latest', + product, ...(pageCursor ? { cursor: pageCursor } : {}), }; diff --git a/tests/commands.search.test.ts b/tests/commands.search.test.ts index ffbfc37..9a0476c 100644 --- a/tests/commands.search.test.ts +++ b/tests/commands.search.test.ts @@ -82,8 +82,8 @@ describe('search command', () => { const searchSpy = vi.spyOn(TwitterClient.prototype, 'search').mockResolvedValue({ success: true, tweets: [] }); try { - await program.parseAsync(['node', 'bird', 'search', 'cats', '--all', '--json']); - expect(getAllSpy).toHaveBeenCalledWith('cats', expect.objectContaining({ includeRaw: false })); + await program.parseAsync(['node', 'bird', 'search', 'cats', '--all', '--json', '--product', 'Top']); + expect(getAllSpy).toHaveBeenCalledWith('cats', expect.objectContaining({ includeRaw: false, product: 'Top' })); expect(searchSpy).not.toHaveBeenCalled(); expect(mockContext.printTweetsResult).toHaveBeenCalledWith(expect.objectContaining({ tweets: [] }), { json: true, @@ -96,6 +96,30 @@ describe('search command', () => { } }); + it('passes --product to non-paged search', async () => { + registerSearchCommands(program, mockContext as CliContext); + const searchSpy = vi.spyOn(TwitterClient.prototype, 'search').mockResolvedValue({ success: true, tweets: [] }); + + try { + await program.parseAsync(['node', 'bird', 'search', 'cats', '--product', 'Top']); + expect(searchSpy).toHaveBeenCalledWith('cats', 10, { + includeRaw: false, + product: 'Top', + }); + } finally { + searchSpy.mockRestore(); + } + }); + + it('rejects unsupported search products', async () => { + program.exitOverride(); + registerSearchCommands(program, mockContext as CliContext); + + await expect(program.parseAsync(['node', 'bird', 'search', 'cats', '--product', 'Media'])).rejects.toMatchObject({ + code: 'commander.invalidArgument', + }); + }); + it('uses cursor pagination when --cursor is set', async () => { registerSearchCommands(program, mockContext as CliContext); const getAllSpy = vi diff --git a/tests/library-exports.test.ts b/tests/library-exports.test.ts index bdb1d7d..97634fe 100644 --- a/tests/library-exports.test.ts +++ b/tests/library-exports.test.ts @@ -1,8 +1,15 @@ import { describe, expect, it } from 'vitest'; -import { TwitterClient } from '../src/index.js'; +import { type SearchFetchOptions, type SearchProduct, TwitterClient } from '../src/index.js'; describe('library exports', () => { it('exposes primary library surface', () => { expect(typeof TwitterClient).toBe('function'); }); + + it('exposes search product types', () => { + const product: SearchProduct = 'Top'; + const options: SearchFetchOptions = { product }; + + expect(options.product).toBe('Top'); + }); }); diff --git a/tests/twitter-client.search-bookmarks.test.ts b/tests/twitter-client.search-bookmarks.test.ts index 83a0bee..ae81029 100644 --- a/tests/twitter-client.search-bookmarks.test.ts +++ b/tests/twitter-client.search-bookmarks.test.ts @@ -83,8 +83,12 @@ describe('TwitterClient search', () => { expect(body.queryId).toBeDefined(); const urlVars = new URL(url as string).searchParams.get('variables'); expect(urlVars).toBeTruthy(); - const parsed = JSON.parse(urlVars as string) as { rawQuery?: string }; + const parsed = JSON.parse(urlVars as string) as { + rawQuery?: string; + product?: string; + }; expect(parsed.rawQuery).toBe('needle'); + expect(parsed.product).toBe('Latest'); }); it('refreshes query IDs when all search endpoints 404', async () => { @@ -186,7 +190,7 @@ describe('TwitterClient search', () => { }); const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl }); - const result = await client.search('needle', 3); + const result = await client.search('needle', 3, { product: 'Top' }); expect(result.success).toBe(true); expect(result.tweets?.map((tweet) => tweet.id)).toEqual(['1', '2', '3']); @@ -194,13 +198,15 @@ describe('TwitterClient search', () => { const firstVars = JSON.parse( new URL(mockFetch.mock.calls[0][0] as string).searchParams.get('variables') as string, - ) as { cursor?: string }; + ) as { cursor?: string; product?: string }; const secondVars = JSON.parse( new URL(mockFetch.mock.calls[1][0] as string).searchParams.get('variables') as string, - ) as { cursor?: string }; + ) as { cursor?: string; product?: string }; expect(firstVars.cursor).toBeUndefined(); expect(secondVars.cursor).toBe('cursor-1'); + expect(firstVars.product).toBe('Top'); + expect(secondVars.product).toBe('Top'); }); it('stops paginating when the cursor repeats', async () => {