2 Commits
Author SHA1 Message Date
yuta b8400469ca Release v0.10.0
CI / test (push) Successful in 9m59s
Publish npm package / publish (push) Successful in 9m53s
2026-07-14 02:35:26 +09:00
yuta 81921226f0 feat: support ranked tweet search
CI / test (push) Successful in 10m17s
2026-07-13 09:18:42 +09:00
9 changed files with 79 additions and 15 deletions
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## 0.10.0 — 2026-07-14
### Added
- Add Top and Latest product selection to library and CLI search.
## 0.9.0 — 2026-06-26
### Added
+6 -1
View File
@@ -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 <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 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 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`.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@yuta/bird",
"version": "0.9.0",
"version": "0.10.0",
"description": "CLI tool for tweeting and replying via Twitter/X GraphQL API",
"type": "module",
"main": "dist/index.js",
+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 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 <number>', 'Stop after N pages when using --all')
.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-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);
+5 -1
View File
@@ -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 {
+6 -2
View File
@@ -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<TBase extends AbstractConstructor<TwitterClientBase>>
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<TBase extends AbstractConstructor<TwitterClientBase>>
rawQuery: query,
count: pageCount,
querySource: 'typed_query',
product: 'Latest',
product,
...(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: [] });
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
+8 -1
View File
@@ -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');
});
});
+10 -4
View File
@@ -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 () => {