Add paged bookmark export with gating and retries

This commit is contained in:
Haoli
2026-01-03 15:51:49 +01:00
committed by Peter Steinberger
parent 533db2d210
commit 56ca7b9841
6 changed files with 703 additions and 160 deletions
@@ -413,6 +413,280 @@ describe('TwitterClient bookmarks', () => {
const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string);
expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBe(true);
});
it('paginates bookmarks when fetching all', 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: '2',
legacy: {
full_text: 'saved 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 result = await client.getAllBookmarks();
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('respects maxPages when fetching all', 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: '2',
legacy: {
full_text: 'saved 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 result = await client.getAllBookmarks({ maxPages: 1 });
expect(result.success).toBe(true);
expect(result.tweets?.map((tweet) => tweet.id)).toEqual(['1']);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('treats graphql errors as non-fatal when instructions are present', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
errors: [{ message: 'Query: Unspecified' }],
data: {
bookmark_timeline_v2: {
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
rest_id: '1',
legacy: {
full_text: 'saved with warning',
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' },
},
},
},
},
},
},
},
},
],
},
],
},
},
},
}),
});
const client = new TwitterClient({ cookies: validCookies });
const result = await client.getAllBookmarks({ maxPages: 1 });
expect(result.success).toBe(true);
expect(result.tweets?.map((tweet) => tweet.id)).toEqual(['1']);
});
});
describe('TwitterClient bookmark folders', () => {