Add home timeline pagination flags
CI / test (push) Successful in 9m58s

This commit is contained in:
2026-06-24 20:17:02 +09:00
parent 01f4b58b99
commit 826de66d78
5 changed files with 272 additions and 30 deletions
+1 -1
View File
@@ -175,7 +175,7 @@ Fields:
- `bird reply <tweet-id-or-url> "<text>"` — reply to a tweet using its ID or URL. - `bird reply <tweet-id-or-url> "<text>"` — reply to a tweet using its ID or URL.
- `bird help [command]` — show help (or help for a subcommand). - `bird help [command]` — show help (or help for a subcommand).
- `bird query-ids [--fresh] [--json]` — inspect or refresh cached GraphQL query IDs. - `bird query-ids [--fresh] [--json]` — inspect or refresh cached GraphQL query IDs.
- `bird home [-n count] [--following] [--json] [--json-full]` — fetch your home timeline (For You) or Following feed. - `bird home [-n count] [--following] [--all] [--max-pages n] [--cursor string] [--delay ms] [--json] [--json-full]` — fetch your home timeline (For You) or Following feed; `--max-pages` implies pagination.
- `bird read <tweet-id-or-url> [--json]` — fetch tweet content as text or JSON. - `bird read <tweet-id-or-url> [--json]` — fetch tweet content as text or JSON.
- `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.
+63 -25
View File
@@ -1,4 +1,5 @@
import type { Command } from 'commander'; import type { Command } from 'commander';
import { parsePaginationFlags } from '../cli/pagination.js';
import type { CliContext } from '../cli/shared.js'; import type { CliContext } from '../cli/shared.js';
import { TwitterClient } from '../lib/twitter-client.js'; import { TwitterClient } from '../lib/twitter-client.js';
@@ -7,36 +8,73 @@ export function registerHomeCommand(program: Command, ctx: CliContext): void {
.command('home') .command('home')
.description('Get your home timeline ("For You" feed)') .description('Get your home timeline ("For You" feed)')
.option('-n, --count <number>', 'Number of tweets to fetch', '20') .option('-n, --count <number>', 'Number of tweets to fetch', '20')
.option('--all', 'Fetch all available pages up to --count')
.option('--max-pages <number>', 'Stop after N pages')
.option('--cursor <string>', 'Resume pagination from a cursor')
.option('--delay <ms>', 'Delay in ms between page fetches', '1000')
.option('--following', 'Get "Following" feed (chronological) instead of "For You"') .option('--following', 'Get "Following" feed (chronological) instead of "For You"')
.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(async (cmdOpts: { count?: string; following?: boolean; json?: boolean; jsonFull?: boolean }) => { .action(
const opts = program.opts(); async (cmdOpts: {
const timeoutMs = ctx.resolveTimeoutFromOptions(opts); count?: string;
const count = Number.parseInt(cmdOpts.count || '20', 10); all?: boolean;
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts); maxPages?: string;
const profileName = ctx.resolveProfileNameFromOptions(opts); cursor?: string;
delay?: string;
following?: boolean;
json?: boolean;
jsonFull?: boolean;
}) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
const pagination = parsePaginationFlags(cmdOpts, {
maxPagesImpliesPagination: true,
includeDelay: true,
});
if (!pagination.ok) {
console.error(`${ctx.p('err')}${pagination.error}`);
process.exit(1);
}
const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts);
const profileName = ctx.resolveProfileNameFromOptions(opts);
if (!Number.isFinite(count) || count <= 0) { if (!Number.isFinite(count) || count <= 0) {
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`); console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
process.exit(1); process.exit(1);
} }
const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs }); const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs });
const includeRaw = cmdOpts.jsonFull ?? false; const includeRaw = cmdOpts.jsonFull ?? false;
const usePagination = pagination.usePagination;
const fetchOptions = {
includeRaw,
maxPages: pagination.maxPages,
cursor: pagination.cursor,
pageDelayMs: pagination.pageDelayMs,
};
const result = cmdOpts.following const result = cmdOpts.following
? await client.getHomeLatestTimeline(count, { includeRaw }) ? await client.getHomeLatestTimeline(count, fetchOptions)
: await client.getHomeTimeline(count, { includeRaw }); : await client.getHomeTimeline(count, fetchOptions);
if (result.success) { if (result.success) {
const feedType = cmdOpts.following ? 'Following' : 'For You'; const feedType = cmdOpts.following ? 'Following' : 'For You';
const emptyMessage = `No tweets found in ${feedType} timeline.`; const emptyMessage = `No tweets found in ${feedType} timeline.`;
const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull); const isJson = Boolean(cmdOpts.json || cmdOpts.jsonFull);
ctx.printTweets(result.tweets, { json: isJson, emptyMessage }); ctx.printTweetsResult(result, {
} else { json: isJson,
console.error(`${ctx.p('err')}Failed to fetch home timeline: ${result.error}`); usePagination,
process.exit(1); emptyMessage,
} });
}); if (result.nextCursor && !cmdOpts.json && !cmdOpts.jsonFull) {
console.error(`${ctx.p('info')}More tweets available. Use --cursor "${result.nextCursor}" to continue.`);
}
} else {
console.error(`${ctx.p('err')}Failed to fetch home timeline: ${result.error}`);
process.exit(1);
}
},
);
} }
+32 -4
View File
@@ -14,6 +14,12 @@ function isQueryIdMismatch(errors: Array<{ message?: string }>): boolean {
export interface HomeTimelineFetchOptions { export interface HomeTimelineFetchOptions {
/** Include raw GraphQL response in `_raw` field */ /** Include raw GraphQL response in `_raw` field */
includeRaw?: boolean; includeRaw?: boolean;
/** Starting cursor for pagination */
cursor?: string;
/** Maximum number of pages to fetch */
maxPages?: number;
/** Delay in milliseconds between page fetches */
pageDelayMs?: number;
} }
export interface TwitterClientHomeMethods { export interface TwitterClientHomeMethods {
@@ -64,7 +70,17 @@ export function withHome<TBase extends AbstractConstructor<TwitterClientBase>>(
const pageSize = 20; const pageSize = 20;
const seen = new Set<string>(); const seen = new Set<string>();
const tweets: TweetData[] = []; const tweets: TweetData[] = [];
let cursor: string | undefined; let cursor: string | undefined = options.cursor;
let nextCursor: string | undefined;
let pagesFetched = 0;
const effectiveMaxPages =
options.maxPages !== undefined && Number.isFinite(options.maxPages) && options.maxPages > 0
? Math.floor(options.maxPages)
: Number.POSITIVE_INFINITY;
const pageDelayMs =
options.pageDelayMs !== undefined && Number.isFinite(options.pageDelayMs) && options.pageDelayMs > 0
? options.pageDelayMs
: 0;
const fetchPage = async (pageCount: number, pageCursor?: string) => { const fetchPage = async (pageCount: number, pageCursor?: string) => {
let lastError: string | undefined; let lastError: string | undefined;
@@ -168,11 +184,16 @@ export function withHome<TBase extends AbstractConstructor<TwitterClientBase>>(
}; };
while (tweets.length < count) { while (tweets.length < count) {
if (pagesFetched > 0 && pageDelayMs > 0) {
await this.sleep(pageDelayMs);
}
const pageCount = Math.min(pageSize, count - tweets.length); const pageCount = Math.min(pageSize, count - tweets.length);
const page = await fetchWithRefresh(pageCount, cursor); const page = await fetchWithRefresh(pageCount, cursor);
if (!page.success) { if (!page.success) {
return { success: false, error: page.error }; return { success: false, error: page.error };
} }
pagesFetched += 1;
let added = 0; let added = 0;
for (const tweet of page.tweets) { for (const tweet of page.tweets) {
@@ -187,13 +208,20 @@ export function withHome<TBase extends AbstractConstructor<TwitterClientBase>>(
} }
} }
if (!page.cursor || page.cursor === cursor || page.tweets.length === 0 || added === 0) { const pageCursor = page.cursor;
if (!pageCursor || pageCursor === cursor || page.tweets.length === 0 || added === 0) {
nextCursor = undefined;
break; break;
} }
cursor = page.cursor; if (pagesFetched >= effectiveMaxPages) {
nextCursor = pageCursor;
break;
}
cursor = pageCursor;
nextCursor = pageCursor;
} }
return { success: true, tweets }; return { success: true, tweets, nextCursor };
} }
} }
+61
View File
@@ -0,0 +1,61 @@
import { Command } from 'commander';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { CliContext } from '../src/cli/shared.js';
import { registerHomeCommand } from '../src/commands/home.js';
import { TwitterClient } from '../src/lib/twitter-client.js';
describe('home command', () => {
let program: Command;
let mockContext: Partial<CliContext>;
beforeEach(() => {
program = new Command();
mockContext = {
resolveTimeoutFromOptions: () => undefined,
resolveRelayBaseUrlFromOptions: () => 'https://relay.example',
resolveProfileNameFromOptions: () => undefined,
p: () => '',
printTweetsResult: vi.fn(),
};
});
it('uses cursor pagination when --cursor is set', async () => {
registerHomeCommand(program, mockContext as CliContext);
const timelineSpy = vi
.spyOn(TwitterClient.prototype, 'getHomeLatestTimeline')
.mockResolvedValue({ success: true, tweets: [], nextCursor: 'next-1' });
try {
await program.parseAsync(['node', 'bird', 'home', '--following', '--cursor', 'cursor-1']);
expect(timelineSpy).toHaveBeenCalledWith(20, expect.objectContaining({ cursor: 'cursor-1' }));
expect(mockContext.printTweetsResult).toHaveBeenCalledWith(expect.objectContaining({ nextCursor: 'next-1' }), {
json: false,
usePagination: true,
emptyMessage: 'No tweets found in Following timeline.',
});
} finally {
timelineSpy.mockRestore();
}
});
it('uses paged home timeline when --max-pages is set', async () => {
registerHomeCommand(program, mockContext as CliContext);
const timelineSpy = vi.spyOn(TwitterClient.prototype, 'getHomeTimeline').mockResolvedValue({
success: true,
tweets: [],
nextCursor: 'next-2',
});
try {
await program.parseAsync(['node', 'bird', 'home', '--max-pages', '3', '--json']);
expect(timelineSpy).toHaveBeenCalledWith(20, expect.objectContaining({ maxPages: 3 }));
expect(mockContext.printTweetsResult).toHaveBeenCalledWith(expect.objectContaining({ nextCursor: 'next-2' }), {
json: true,
usePagination: true,
emptyMessage: 'No tweets found in For You timeline.',
});
} finally {
timelineSpy.mockRestore();
}
});
});
+115
View File
@@ -145,6 +145,121 @@ describe('TwitterClient home timeline', () => {
}); });
describe('pagination', () => { describe('pagination', () => {
it('returns the next cursor when stopping before the end', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
home: {
home_timeline_urt: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet1',
legacy: { full_text: 'First tweet', created_at: 'Mon Jan 06 00:00:00 +0000 2025' },
core: {
user_results: {
result: { rest_id: 'u1', legacy: { screen_name: 'user1', name: 'User 1' } },
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor123',
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getHomeTimeline(1);
expect(result.success).toBe(true);
expect(result.nextCursor).toBe('cursor123');
});
it('resumes from a provided cursor', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => makeHomeTimelineResponse('tweet2', 'Second page tweet', 'user2'),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getHomeLatestTimeline(1, { cursor: 'resume-cursor' });
expect(result.success).toBe(true);
const callUrl = String(mockFetch.mock.calls[0]?.[0]);
const variables = JSON.parse(new URL(callUrl).searchParams.get('variables') ?? '{}') as { cursor?: string };
expect(variables.cursor).toBe('resume-cursor');
});
it('stops at maxPages and returns the cursor to resume', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
data: {
home: {
home_timeline_urt: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet1',
legacy: { full_text: 'First tweet', created_at: 'Mon Jan 06 00:00:00 +0000 2025' },
core: {
user_results: {
result: { rest_id: 'u1', legacy: { screen_name: 'user1', name: 'User 1' } },
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-page-2',
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl });
const result = await client.getHomeTimeline(5, { maxPages: 1 });
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(1);
expect(result.nextCursor).toBe('cursor-page-2');
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('should deduplicate tweets across pages', async () => { it('should deduplicate tweets across pages', async () => {
// First page // First page
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce({