feat: add pagination support to list-timeline command

Adds --all, --max-pages, and --cursor options to list-timeline command, enabling users to fetch all tweets from a list through automatic pagination. Follows the same pattern as bookmarks pagination for consistency.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
This commit is contained in:
Zhe Li
2026-01-11 11:17:27 +00:00
committed by Peter Steinberger
co-authored by Claude Sonnet 4.5
parent bda980fa4d
commit 803bd25b0c
3 changed files with 686 additions and 34 deletions
+565
View File
@@ -884,4 +884,569 @@ describe('TwitterClient lists', () => {
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
describe('getAllListTimeline', () => {
it('fetches multiple pages and returns all tweets', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet1',
legacy: {
full_text: 'First tweet',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'tweet1',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'user1', name: 'User 1' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-page-2',
},
},
],
},
],
},
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet2',
legacy: {
full_text: 'Second tweet',
created_at: '2024-01-02T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'tweet2',
},
core: {
user_results: {
result: {
rest_id: 'u2',
legacy: { screen_name: 'user2', name: 'User 2' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getAllListTimeline('1234567890');
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(2);
expect(result.tweets?.[0].id).toBe('tweet1');
expect(result.tweets?.[1].id).toBe('tweet2');
expect(result.nextCursor).toBeUndefined();
expect(mockFetch).toHaveBeenCalledTimes(2);
// Verify cursor was passed in second request
const secondCallUrl = (mockFetch.mock.calls[1] as [string])[0];
expect(secondCallUrl).toContain('cursor-page-2');
});
it('stops at maxPages limit and returns nextCursor', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet1',
legacy: {
full_text: 'Page 1',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'tweet1',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'user1', name: 'User 1' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-page-2',
},
},
],
},
],
},
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet2',
legacy: {
full_text: 'Page 2',
created_at: '2024-01-02T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'tweet2',
},
core: {
user_results: {
result: {
rest_id: 'u2',
legacy: { screen_name: 'user2', name: 'User 2' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-page-3',
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getAllListTimeline('1234567890', { maxPages: 2 });
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(2);
expect(result.nextCursor).toBe('cursor-page-3');
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('resumes from cursor', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet-resumed',
legacy: {
full_text: 'Resumed tweet',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'tweet-resumed',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'user1', name: 'User 1' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getAllListTimeline('1234567890', { cursor: 'resume-cursor' });
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(1);
expect(result.tweets?.[0].id).toBe('tweet-resumed');
// Verify cursor was passed in the request
const callUrl = (mockFetch.mock.calls[0] as [string])[0];
expect(callUrl).toContain('resume-cursor');
});
it('deduplicates tweets across pages', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'duplicate-tweet',
legacy: {
full_text: 'Duplicate',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'duplicate-tweet',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'user1', name: 'User 1' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-page-2',
},
},
],
},
],
},
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'duplicate-tweet',
legacy: {
full_text: 'Duplicate',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'duplicate-tweet',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'user1', name: 'User 1' },
},
},
},
},
},
},
},
},
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'unique-tweet',
legacy: {
full_text: 'Unique',
created_at: '2024-01-02T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'unique-tweet',
},
core: {
user_results: {
result: {
rest_id: 'u2',
legacy: { screen_name: 'user2', name: 'User 2' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getAllListTimeline('1234567890');
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(2);
expect(result.tweets?.[0].id).toBe('duplicate-tweet');
expect(result.tweets?.[1].id).toBe('unique-tweet');
});
it('stops when no cursor is returned', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet1',
legacy: {
full_text: 'Page 1',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'tweet1',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'user1', name: 'User 1' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-page-2',
},
},
],
},
],
},
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
list: {
tweets_timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: 'tweet2',
legacy: {
full_text: 'Last page',
created_at: '2024-01-02T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: 'tweet2',
},
core: {
user_results: {
result: {
rest_id: 'u2',
legacy: { screen_name: 'user2', name: 'User 2' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getListTimelineQueryIds = async () => ['test'];
const result = await client.getAllListTimeline('1234567890');
expect(result.success).toBe(true);
expect(result.tweets).toHaveLength(2);
expect(result.nextCursor).toBeUndefined();
expect(mockFetch).toHaveBeenCalledTimes(2);
});
});
});