fix: paginate likes + avoid duplicate loops (#12) (thanks @titouv)

This commit is contained in:
Peter Steinberger
2026-01-11 10:31:47 +00:00
parent 7548fd5ed1
commit 2e03bd8c07
4 changed files with 245 additions and 2 deletions
+1
View File
@@ -4,6 +4,7 @@
### Fixed ### Fixed
- macOS cookie extraction now supports Brave keychain storage (#40) — thanks @gakonst. - macOS cookie extraction now supports Brave keychain storage (#40) — thanks @gakonst.
- Likes pagination now follows cursors and avoids stalling on duplicate pages (#12) — thanks @titouv.
## 0.6.0 — 2026-01-05 ## 0.6.0 — 2026-01-05
+3 -2
View File
@@ -150,7 +150,7 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
}; };
}; };
errors?: Array<{ message: string }>; errors?: Array<{ message: string }>;
}; };
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions; const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
if (data.errors && data.errors.length > 0) { if (data.errors && data.errors.length > 0) {
@@ -181,7 +181,8 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
return firstAttempt; return firstAttempt;
} }
const shouldRefresh = const shouldRefresh =
firstAttempt.had404 || (typeof firstAttempt.error === 'string' && firstAttempt.error.includes('Query: Unspecified')); firstAttempt.had404 ||
(typeof firstAttempt.error === 'string' && firstAttempt.error.includes('Query: Unspecified'));
if (shouldRefresh) { if (shouldRefresh) {
await this.refreshQueryIds(); await this.refreshQueryIds();
const secondAttempt = await fetchPage(pageCount, pageCursor); const secondAttempt = await fetchPage(pageCount, pageCursor);
@@ -365,6 +365,133 @@ describe('TwitterClient likes', () => {
expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBeUndefined(); expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBeUndefined();
}); });
it('paginates likes when count exceeds the first page', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '1',
legacy: {
full_text: 'liked page 1',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '1',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'root', name: 'Root' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-1',
},
},
],
},
],
},
},
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
user: {
result: {
timeline: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '2',
legacy: {
full_text: 'liked page 2',
created_at: '2024-01-02T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '2',
},
core: {
user_results: {
result: {
rest_id: 'u2',
legacy: { screen_name: 'root', name: 'Root' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate;
clientPrivate.getCurrentUser = async () => ({
success: true,
user: { id: '42', username: 'tester', name: 'Tester' },
});
clientPrivate.getLikesQueryIds = async () => ['test'];
const result = await client.getLikes(3);
expect(result.success).toBe(true);
expect(result.tweets?.map((tweet) => tweet.id)).toEqual(['1', '2']);
expect(mockFetch).toHaveBeenCalledTimes(2);
const secondVars = JSON.parse(
new URL(mockFetch.mock.calls[1][0] as string).searchParams.get('variables') as string,
);
expect(secondVars.cursor).toBe('cursor-1');
});
it('returns an error when current user is unavailable', async () => { it('returns an error when current user is unavailable', async () => {
const client = new TwitterClient({ cookies: validCookies }); const client = new TwitterClient({ cookies: validCookies });
const clientPrivate = client as unknown as TwitterClientPrivate; const clientPrivate = client as unknown as TwitterClientPrivate;
@@ -691,6 +691,120 @@ describe('TwitterClient bookmarks', () => {
expect(vars.cursor).toBe('cursor-1'); expect(vars.cursor).toBe('cursor-1');
}); });
it('stops paginating when a page only returns duplicates', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
bookmark_timeline_v2: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '1',
legacy: {
full_text: 'saved page 1',
created_at: '2024-01-01T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '1',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'root', name: 'Root' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-1',
},
},
],
},
],
},
},
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
data: {
bookmark_timeline_v2: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '1',
legacy: {
full_text: 'saved page 2 duplicate',
created_at: '2024-01-02T00:00:00Z',
reply_count: 0,
retweet_count: 0,
favorite_count: 0,
conversation_id_str: '1',
},
core: {
user_results: {
result: {
rest_id: 'u1',
legacy: { screen_name: 'root', name: 'Root' },
},
},
},
},
},
},
},
},
{
content: {
cursorType: 'Bottom',
value: 'cursor-2',
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getAllBookmarks();
expect(result.success).toBe(true);
expect(result.tweets?.map((tweet) => tweet.id)).toEqual(['1']);
expect(result.nextCursor).toBeUndefined();
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('treats graphql errors as non-fatal when instructions are present', async () => { it('treats graphql errors as non-fatal when instructions are present', async () => {
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce({
ok: true, ok: true,