fix: paginate likes + avoid duplicate loops (#12) (thanks @titouv)
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user