This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,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 } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user