feat: support ranked tweet search
CI / test (push) Successful in 10m17s

This commit is contained in:
2026-07-13 09:18:42 +09:00
parent 0caf2677e4
commit 81921226f0
8 changed files with 78 additions and 14 deletions
+6
View File
@@ -1,5 +1,11 @@
# Changelog # Changelog
## Unreleased
### Added
- Add Top and Latest product selection to library and CLI search.
## 0.9.0 — 2026-06-26 ## 0.9.0 — 2026-06-26
### Added ### Added
+6 -1
View File
@@ -51,6 +51,8 @@ bird thread 1234567890123456789 --max-pages 3 --json
# Search + mentions # Search + mentions
bird search "from:steipete" -n 5 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 -n 5
bird mentions --user @steipete -n 5 bird mentions --user @steipete -n 5
@@ -145,6 +147,9 @@ const client = new TwitterClient({
// Search for tweets // Search for tweets
const searchResult = await client.search('from:steipete', 50); 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) // Fetch news and trending topics from all tabs (default: For You, News, Sports, Entertainment)
const newsResult = await client.getNews(10, { aiOnly: true }); const newsResult = await client.getNews(10, { aiOnly: true });
@@ -184,7 +189,7 @@ Fields:
- `bird <tweet-id-or-url> [--json]` — shorthand for `read` when only a URL or ID is provided. - `bird <tweet-id-or-url> [--json]` — shorthand for `read` when only a URL or ID is provided.
- `bird replies <tweet-id-or-url> [--all] [--max-pages n] [--cursor string] [--delay ms] [--json]` — list replies to a tweet. - `bird replies <tweet-id-or-url> [--all] [--max-pages n] [--cursor string] [--delay ms] [--json]` — list replies to a tweet.
- `bird thread <tweet-id-or-url> [--all] [--max-pages n] [--cursor string] [--delay ms] [--json]` — show the full conversation thread. - `bird thread <tweet-id-or-url> [--all] [--max-pages n] [--cursor string] [--delay ms] [--json]` — show the full conversation thread.
- `bird search "<query>" [-n count] [--all] [--max-pages n] [--cursor string] [--json]` — search for tweets matching a query; `--max-pages` requires `--all` or `--cursor`. - `bird search "<query>" [-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 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 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`. - `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`.
+11 -3
View File
@@ -1,8 +1,9 @@
import type { Command } from 'commander'; import { type Command, Option } from 'commander';
import { parsePaginationFlags } from '../cli/pagination.js'; import { parsePaginationFlags } from '../cli/pagination.js';
import type { CliContext } from '../cli/shared.js'; import type { CliContext } from '../cli/shared.js';
import { mentionsQueryFromUserOption, normalizeHandle } from '../lib/normalize-handle.js'; import { mentionsQueryFromUserOption, normalizeHandle } from '../lib/normalize-handle.js';
import { TwitterClient } from '../lib/twitter-client.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 { export function registerSearchCommands(program: Command, ctx: CliContext): void {
program program
@@ -13,6 +14,11 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void
.option('--all', 'Fetch all search results (paged)') .option('--all', 'Fetch all search results (paged)')
.option('--max-pages <number>', 'Stop after N pages when using --all') .option('--max-pages <number>', 'Stop after N pages when using --all')
.option('--cursor <string>', 'Resume pagination from a cursor') .option('--cursor <string>', 'Resume pagination from a cursor')
.addOption(
new Option('--product <product>', 'Search product')
.choices(['Top', 'Latest'] satisfies SearchProduct[])
.default('Latest'),
)
.option('--json', 'Output as JSON') .option('--json', 'Output as JSON')
.option('--json-full', 'Output as JSON with full raw API response in _raw field') .option('--json-full', 'Output as JSON with full raw API response in _raw field')
.action( .action(
@@ -23,6 +29,7 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void
all?: boolean; all?: boolean;
maxPages?: string; maxPages?: string;
cursor?: string; cursor?: string;
product?: SearchProduct;
json?: boolean; json?: boolean;
jsonFull?: boolean; jsonFull?: boolean;
}, },
@@ -53,8 +60,9 @@ export function registerSearchCommands(program: Command, ctx: CliContext): void
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth }); const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs, quoteDepth });
const includeRaw = cmdOpts.jsonFull ?? false; const includeRaw = cmdOpts.jsonFull ?? false;
const searchOptions = { includeRaw }; const product = cmdOpts.product ?? 'Latest';
const paginationOptions = { includeRaw, maxPages, cursor: pagination.cursor }; const searchOptions = { includeRaw, product };
const paginationOptions = { includeRaw, maxPages, cursor: pagination.cursor, product };
const result = usePagination const result = usePagination
? await client.getAllSearchResults(query, paginationOptions) ? await client.getAllSearchResults(query, paginationOptions)
: await client.search(query, count, searchOptions); : await client.search(query, count, searchOptions);
+5 -1
View File
@@ -11,7 +11,11 @@ export {
} from './twitter-client.js'; } from './twitter-client.js';
export type { HomeTimelineFetchOptions } from './twitter-client-home.js'; export type { HomeTimelineFetchOptions } from './twitter-client-home.js';
export type { ExploreTab, 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 {
SearchFetchOptions,
SearchPaginationOptions,
SearchProduct,
} from './twitter-client-search.js';
export type { TimelineFetchOptions } from './twitter-client-timelines.js'; export type { TimelineFetchOptions } from './twitter-client-timelines.js';
export type { TweetFetchOptions } from './twitter-client-tweet-detail.js'; export type { TweetFetchOptions } from './twitter-client-tweet-detail.js';
export type { export type {
+6 -2
View File
@@ -6,10 +6,14 @@ import { extractCursorFromInstructions, parseTweetsFromInstructions } from './tw
const RAW_QUERY_MISSING_REGEX = /must be defined/i; const RAW_QUERY_MISSING_REGEX = /must be defined/i;
export type SearchProduct = 'Top' | 'Latest';
/** Options for search methods */ /** Options for search methods */
export interface SearchFetchOptions { export interface SearchFetchOptions {
/** Include raw GraphQL response in `_raw` field */ /** Include raw GraphQL response in `_raw` field */
includeRaw?: boolean; includeRaw?: boolean;
/** Search result ranking (default: Latest) */
product?: SearchProduct;
} }
/** Options for paged search methods */ /** Options for paged search methods */
@@ -80,7 +84,7 @@ export function withSearch<TBase extends AbstractConstructor<TwitterClientBase>>
let cursor: string | undefined = options.cursor; let cursor: string | undefined = options.cursor;
let nextCursor: string | undefined; let nextCursor: string | undefined;
let pagesFetched = 0; let pagesFetched = 0;
const { includeRaw = false, maxPages } = options; const { includeRaw = false, maxPages, product = 'Latest' } = options;
const fetchPage = async (pageCount: number, pageCursor?: string) => { const fetchPage = async (pageCount: number, pageCursor?: string) => {
let lastError: string | undefined; let lastError: string | undefined;
@@ -92,7 +96,7 @@ export function withSearch<TBase extends AbstractConstructor<TwitterClientBase>>
rawQuery: query, rawQuery: query,
count: pageCount, count: pageCount,
querySource: 'typed_query', querySource: 'typed_query',
product: 'Latest', product,
...(pageCursor ? { cursor: pageCursor } : {}), ...(pageCursor ? { cursor: pageCursor } : {}),
}; };
+26 -2
View File
@@ -82,8 +82,8 @@ describe('search command', () => {
const searchSpy = vi.spyOn(TwitterClient.prototype, 'search').mockResolvedValue({ success: true, tweets: [] }); const searchSpy = vi.spyOn(TwitterClient.prototype, 'search').mockResolvedValue({ success: true, tweets: [] });
try { try {
await program.parseAsync(['node', 'bird', 'search', 'cats', '--all', '--json']); await program.parseAsync(['node', 'bird', 'search', 'cats', '--all', '--json', '--product', 'Top']);
expect(getAllSpy).toHaveBeenCalledWith('cats', expect.objectContaining({ includeRaw: false })); expect(getAllSpy).toHaveBeenCalledWith('cats', expect.objectContaining({ includeRaw: false, product: 'Top' }));
expect(searchSpy).not.toHaveBeenCalled(); expect(searchSpy).not.toHaveBeenCalled();
expect(mockContext.printTweetsResult).toHaveBeenCalledWith(expect.objectContaining({ tweets: [] }), { expect(mockContext.printTweetsResult).toHaveBeenCalledWith(expect.objectContaining({ tweets: [] }), {
json: true, 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 () => { it('uses cursor pagination when --cursor is set', async () => {
registerSearchCommands(program, mockContext as CliContext); registerSearchCommands(program, mockContext as CliContext);
const getAllSpy = vi const getAllSpy = vi
+8 -1
View File
@@ -1,8 +1,15 @@
import { describe, expect, it } from 'vitest'; 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', () => { describe('library exports', () => {
it('exposes primary library surface', () => { it('exposes primary library surface', () => {
expect(typeof TwitterClient).toBe('function'); expect(typeof TwitterClient).toBe('function');
}); });
it('exposes search product types', () => {
const product: SearchProduct = 'Top';
const options: SearchFetchOptions = { product };
expect(options.product).toBe('Top');
});
}); });
+10 -4
View File
@@ -83,8 +83,12 @@ describe('TwitterClient search', () => {
expect(body.queryId).toBeDefined(); expect(body.queryId).toBeDefined();
const urlVars = new URL(url as string).searchParams.get('variables'); const urlVars = new URL(url as string).searchParams.get('variables');
expect(urlVars).toBeTruthy(); 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.rawQuery).toBe('needle');
expect(parsed.product).toBe('Latest');
}); });
it('refreshes query IDs when all search endpoints 404', async () => { it('refreshes query IDs when all search endpoints 404', async () => {
@@ -186,7 +190,7 @@ describe('TwitterClient search', () => {
}); });
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl }); 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.success).toBe(true);
expect(result.tweets?.map((tweet) => tweet.id)).toEqual(['1', '2', '3']); expect(result.tweets?.map((tweet) => tweet.id)).toEqual(['1', '2', '3']);
@@ -194,13 +198,15 @@ describe('TwitterClient search', () => {
const firstVars = JSON.parse( const firstVars = JSON.parse(
new URL(mockFetch.mock.calls[0][0] as string).searchParams.get('variables') as string, 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( const secondVars = JSON.parse(
new URL(mockFetch.mock.calls[1][0] as string).searchParams.get('variables') as string, 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(firstVars.cursor).toBeUndefined();
expect(secondVars.cursor).toBe('cursor-1'); expect(secondVars.cursor).toBe('cursor-1');
expect(firstVars.product).toBe('Top');
expect(secondVars.product).toBe('Top');
}); });
it('stops paginating when the cursor repeats', async () => { it('stops paginating when the cursor repeats', async () => {