diff --git a/.gitignore b/.gitignore index 81c765c..5fb892c 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ bird *.tar *.tar.gz *.tgz +bookmarks.json +bookmarks_canary.json +bookmarks_mid.json diff --git a/README.md b/README.md index 6d3a40e..9c7b7f6 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ bird mentions --user @steipete -n 5 # Bookmarks bird bookmarks -n 5 bird bookmarks --folder-id 123456789123456789 -n 5 # https://x.com/i/bookmarks/ +bird bookmarks --all --json +bird bookmarks --all --max-pages 2 --json # Likes bird likes -n 5 @@ -91,7 +93,7 @@ const result = await client.search('from:steipete', 50); - `bird thread [--json]` — show the full conversation thread. - `bird search "" [-n count] [--json]` — search for tweets matching a query. - `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user). -- `bird bookmarks [-n count] [--folder-id id] [--json]` — list your bookmarked tweets (or a specific bookmark folder). +- `bird bookmarks [-n count] [--folder-id id] [--all] [--max-pages n] [--json]` — list your bookmarked tweets (or a specific bookmark folder); `--max-pages` requires `--all`. - `bird likes [-n count] [--json]` — list your liked tweets. - `bird following [--user ] [-n count] [--json]` — list users that you (or another user) follow. - `bird followers [--user ] [-n count] [--json]` — list users that follow you (or another user). diff --git a/src/commands/bookmarks.ts b/src/commands/bookmarks.ts index 06ef2cb..fafc057 100644 --- a/src/commands/bookmarks.ts +++ b/src/commands/bookmarks.ts @@ -9,41 +9,72 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi .description('Get your bookmarked tweets') .option('-n, --count ', 'Number of bookmarks to fetch', '20') .option('--folder-id ', 'Bookmark folder (collection) id') + .option('--all', 'Fetch all bookmarks (paged)') + .option('--max-pages ', 'Stop after N pages when using --all') .option('--json', 'Output as JSON') .option('--json-full', 'Output as JSON with full raw API response in _raw field') - .action(async (cmdOpts: { count?: string; json?: boolean; jsonFull?: boolean; folderId?: string }) => { - const opts = program.opts(); - const timeoutMs = ctx.resolveTimeoutFromOptions(opts); - const count = Number.parseInt(cmdOpts.count || '20', 10); + .action( + async (cmdOpts: { + count?: string; + json?: boolean; + jsonFull?: boolean; + folderId?: string; + all?: boolean; + maxPages?: string; + }) => { + const opts = program.opts(); + const timeoutMs = ctx.resolveTimeoutFromOptions(opts); + const count = Number.parseInt(cmdOpts.count || '20', 10); + const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined; - const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); + const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); - for (const warning of warnings) { - console.error(`${ctx.p('warn')}${warning}`); - } + for (const warning of warnings) { + console.error(`${ctx.p('warn')}${warning}`); + } - if (!cookies.authToken || !cookies.ct0) { - console.error(`${ctx.p('err')}Missing required credentials`); - process.exit(1); - } + if (!cookies.authToken || !cookies.ct0) { + console.error(`${ctx.p('err')}Missing required credentials`); + process.exit(1); + } - const client = new TwitterClient({ cookies, timeoutMs }); - const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null; - if (cmdOpts.folderId && !folderId) { - console.error(`${ctx.p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/.`); - process.exit(1); - } - const includeRaw = cmdOpts.jsonFull ?? false; - const result = folderId - ? await client.getBookmarkFolderTimeline(folderId, count, { includeRaw }) - : await client.getBookmarks(count, { includeRaw }); + if (maxPages !== undefined && !cmdOpts.all) { + console.error(`${ctx.p('err')}--max-pages requires --all.`); + process.exit(1); + } + if (!cmdOpts.all && (!Number.isFinite(count) || count <= 0)) { + console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`); + process.exit(1); + } + if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) { + console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`); + process.exit(1); + } - if (result.success && result.tweets) { - const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.'; - ctx.printTweets(result.tweets, { json: cmdOpts.json || cmdOpts.jsonFull, emptyMessage }); - } else { - console.error(`${ctx.p('err')}Failed to fetch bookmarks: ${result.error}`); - process.exit(1); - } - }); + const client = new TwitterClient({ cookies, timeoutMs }); + const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null; + if (cmdOpts.folderId && !folderId) { + console.error(`${ctx.p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/.`); + process.exit(1); + } + const includeRaw = cmdOpts.jsonFull ?? false; + const timelineOptions = { includeRaw }; + const paginationOptions = { includeRaw, maxPages }; + const result = folderId + ? cmdOpts.all + ? await client.getAllBookmarkFolderTimeline(folderId, paginationOptions) + : await client.getBookmarkFolderTimeline(folderId, count, timelineOptions) + : cmdOpts.all + ? await client.getAllBookmarks(paginationOptions) + : await client.getBookmarks(count, timelineOptions); + + if (result.success && result.tweets) { + const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.'; + ctx.printTweets(result.tweets, { json: cmdOpts.json || cmdOpts.jsonFull, emptyMessage }); + } else { + console.error(`${ctx.p('err')}Failed to fetch bookmarks: ${result.error}`); + process.exit(1); + } + }, + ); } diff --git a/src/lib/twitter-client-timelines.ts b/src/lib/twitter-client-timelines.ts index 119836c..f81ed47 100644 --- a/src/lib/twitter-client-timelines.ts +++ b/src/lib/twitter-client-timelines.ts @@ -1,8 +1,8 @@ import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js'; import { TWITTER_API_BASE } from './twitter-client-constants.js'; import { buildBookmarksFeatures, buildLikesFeatures } from './twitter-client-features.js'; -import type { GraphqlTweetResult, SearchResult } from './twitter-client-types.js'; -import { parseTweetsFromInstructions } from './twitter-client-utils.js'; +import type { GraphqlTweetResult, SearchResult, TweetData } from './twitter-client-types.js'; +import { extractCursorFromInstructions, parseTweetsFromInstructions } from './twitter-client-utils.js'; /** Options for timeline fetch methods */ export interface TimelineFetchOptions { @@ -10,10 +10,17 @@ export interface TimelineFetchOptions { includeRaw?: boolean; } +/** Options for paged timeline fetch methods */ +export interface TimelinePaginationOptions extends TimelineFetchOptions { + maxPages?: number; +} + export interface TwitterClientTimelineMethods { getBookmarks(count?: number, options?: TimelineFetchOptions): Promise; + getAllBookmarks(options?: TimelinePaginationOptions): Promise; getLikes(count?: number, options?: TimelineFetchOptions): Promise; getBookmarkFolderTimeline(folderId: string, count?: number, options?: TimelineFetchOptions): Promise; + getAllBookmarkFolderTimeline(folderId: string, options?: TimelinePaginationOptions): Promise; } export function withTimelines>( @@ -25,6 +32,17 @@ export function withTimelines): void { + if (process.env.BIRD_DEBUG_BOOKMARKS !== '1') { + return; + } + if (data) { + console.error(`[bird][debug][bookmarks] ${message}`, JSON.stringify(data)); + } else { + console.error(`[bird][debug][bookmarks] ${message}`); + } + } + private async getBookmarksQueryIds(): Promise { const primary = await this.getQueryId('Bookmarks'); return Array.from(new Set([primary, 'RV1g3b8n_SGOHwkqKYSCFw', 'tmd4ifV8RHltzn8ymGg1aw'])); @@ -44,99 +62,11 @@ export function withTimelines { - const { includeRaw = false } = options; - const variables = { - count, - includePromotedContent: false, - withDownvotePerspective: false, - withReactionsMetadata: false, - withReactionsPerspective: false, - }; + return this.getBookmarksPaged(count, options); + } - const features = buildBookmarksFeatures(); - - const params = new URLSearchParams({ - variables: JSON.stringify(variables), - features: JSON.stringify(features), - }); - - const tryOnce = async () => { - let lastError: string | undefined; - let had404 = false; - const queryIds = await this.getBookmarksQueryIds(); - - for (const queryId of queryIds) { - const url = `${TWITTER_API_BASE}/${queryId}/Bookmarks?${params.toString()}`; - - try { - const response = await this.fetchWithTimeout(url, { - method: 'GET', - headers: this.getHeaders(), - }); - - if (response.status === 404) { - had404 = true; - lastError = `HTTP ${response.status}`; - continue; - } - - if (!response.ok) { - const text = await response.text(); - return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 }; - } - - const data = (await response.json()) as { - data?: { - bookmark_timeline_v2?: { - timeline?: { - instructions?: Array<{ - entries?: Array<{ - content?: { - itemContent?: { - tweet_results?: { - result?: GraphqlTweetResult; - }; - }; - }; - }>; - }>; - }; - }; - }; - errors?: Array<{ message: string }>; - }; - - if (data.errors && data.errors.length > 0) { - return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 }; - } - - const instructions = data.data?.bookmark_timeline_v2?.timeline?.instructions; - const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw }); - - return { success: true as const, tweets, had404 }; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - } - } - - return { success: false as const, error: lastError ?? 'Unknown error fetching bookmarks', had404 }; - }; - - const firstAttempt = await tryOnce(); - if (firstAttempt.success) { - return { success: true, tweets: firstAttempt.tweets }; - } - - if (firstAttempt.had404) { - await this.refreshQueryIds(); - const secondAttempt = await tryOnce(); - if (secondAttempt.success) { - return { success: true, tweets: secondAttempt.tweets }; - } - return { success: false, error: secondAttempt.error }; - } - - return { success: false, error: firstAttempt.error }; + async getAllBookmarks(options?: TimelinePaginationOptions): Promise { + return this.getBookmarksPaged(Number.POSITIVE_INFINITY, options); } /** @@ -256,24 +186,35 @@ export function withTimelines { - const { includeRaw = false } = options; - const variablesWithCount = { - bookmark_collection_id: folderId, - includePromotedContent: true, - count, - }; + return this.getBookmarkFolderTimelinePaged(folderId, count, options); + } - const variablesWithoutCount = { - bookmark_collection_id: folderId, - includePromotedContent: true, - }; + async getAllBookmarkFolderTimeline(folderId: string, options?: TimelinePaginationOptions): Promise { + return this.getBookmarkFolderTimelinePaged(folderId, Number.POSITIVE_INFINITY, options); + } + private async getBookmarksPaged(limit: number, options: TimelinePaginationOptions = {}): Promise { const features = buildBookmarksFeatures(); + const pageSize = 20; + const seen = new Set(); + const tweets: TweetData[] = []; + let cursor: string | undefined; + let pagesFetched = 0; + const { includeRaw = false, maxPages } = options; - const tryOnce = async (variables: Record) => { + const fetchPage = async (pageCount: number, pageCursor?: string) => { let lastError: string | undefined; let had404 = false; - const queryIds = await this.getBookmarkFolderQueryIds(); + const queryIds = await this.getBookmarksQueryIds(); + + const variables = { + count: pageCount, + includePromotedContent: false, + withDownvotePerspective: false, + withReactionsMetadata: false, + withReactionsPerspective: false, + ...(pageCursor ? { cursor: pageCursor } : {}), + }; const params = new URLSearchParams({ variables: JSON.stringify(variables), @@ -281,10 +222,15 @@ export function withTimelines; }; + const instructions = data.data?.bookmark_timeline_v2?.timeline?.instructions; + const pageTweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw }); + const nextCursor = extractCursorFromInstructions(instructions); + if (data.errors && data.errors.length > 0) { - return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 }; + this.logBookmarksDebug('bookmarks graphql errors (non-fatal)', { queryId, errors: data.errors }); + if (!instructions) { + lastError = data.errors.map((e) => e.message).join(', '); + continue; + } } - const instructions = data.data?.bookmark_collection_timeline?.timeline?.instructions; - const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw }); + this.logBookmarksDebug('bookmarks page parsed', { + queryId, + tweets: pageTweets.length, + hasNextCursor: Boolean(nextCursor), + }); - return { success: true as const, tweets, had404 }; + return { success: true as const, tweets: pageTweets, cursor: nextCursor, had404 }; } catch (error) { lastError = error instanceof Error ? error.message : String(error); + this.logBookmarksDebug('bookmarks request error', { queryId, error: lastError }); } } - return { success: false as const, error: lastError ?? 'Unknown error fetching bookmark folder', had404 }; + return { success: false as const, error: lastError ?? 'Unknown error fetching bookmarks', had404 }; }; - let firstAttempt = await tryOnce(variablesWithCount); - if (!firstAttempt.success && firstAttempt.error?.includes('Variable "$count"')) { - firstAttempt = await tryOnce(variablesWithoutCount); - } - if (firstAttempt.success) { - return { success: true, tweets: firstAttempt.tweets }; + const fetchWithRefresh = async (pageCount: number, pageCursor?: string) => { + const firstAttempt = await fetchPage(pageCount, pageCursor); + if (firstAttempt.success) { + return firstAttempt; + } + if (firstAttempt.had404) { + await this.refreshQueryIds(); + const secondAttempt = await fetchPage(pageCount, pageCursor); + if (secondAttempt.success) { + return secondAttempt; + } + return { success: false as const, error: secondAttempt.error }; + } + return { success: false as const, error: firstAttempt.error }; + }; + + const unlimited = !Number.isFinite(limit); + while (unlimited || tweets.length < limit) { + const pageCount = unlimited ? pageSize : Math.min(pageSize, limit - tweets.length); + const page = await fetchWithRefresh(pageCount, cursor); + if (!page.success) { + return { success: false, error: page.error }; + } + pagesFetched += 1; + + for (const tweet of page.tweets) { + if (seen.has(tweet.id)) { + continue; + } + seen.add(tweet.id); + tweets.push(tweet); + if (!unlimited && tweets.length >= limit) { + break; + } + } + + if (!page.cursor || page.cursor === cursor || page.tweets.length === 0) { + break; + } + if (maxPages && pagesFetched >= maxPages) { + break; + } + cursor = page.cursor; } - if (firstAttempt.had404) { - await this.refreshQueryIds(); - let secondAttempt = await tryOnce(variablesWithCount); - if (!secondAttempt.success && secondAttempt.error?.includes('Variable "$count"')) { - secondAttempt = await tryOnce(variablesWithoutCount); + return { success: true, tweets }; + } + + private async getBookmarkFolderTimelinePaged( + folderId: string, + limit: number, + options: TimelinePaginationOptions = {}, + ): Promise { + const features = buildBookmarksFeatures(); + const pageSize = 20; + const seen = new Set(); + const tweets: TweetData[] = []; + let cursor: string | undefined; + let pagesFetched = 0; + const { includeRaw = false, maxPages } = options; + + const buildVariables = (pageCount: number, pageCursor: string | undefined, includeCount: boolean) => ({ + bookmark_collection_id: folderId, + includePromotedContent: true, + ...(includeCount ? { count: pageCount } : {}), + ...(pageCursor ? { cursor: pageCursor } : {}), + }); + + const fetchPage = async (pageCount: number, pageCursor?: string) => { + let lastError: string | undefined; + let had404 = false; + const queryIds = await this.getBookmarkFolderQueryIds(); + + const tryOnce = async (variables: Record) => { + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(features), + }); + + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/BookmarkFolderTimeline?${params.toString()}`; + + try { + this.logBookmarksDebug('request bookmark folder page', { + queryId, + pageCount, + hasCursor: Boolean(pageCursor), + includeCount: Object.prototype.hasOwnProperty.call(variables, 'count'), + }); + const response = await this.fetchWithRetry(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (response.status === 404) { + had404 = true; + lastError = `HTTP ${response.status}`; + this.logBookmarksDebug('bookmark folder 404', { queryId }); + continue; + } + + if (!response.ok) { + const text = await response.text(); + this.logBookmarksDebug('bookmark folder non-200', { + queryId, + status: response.status, + body: text.slice(0, 200), + }); + return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 }; + } + + const data = (await response.json()) as { + data?: { + bookmark_collection_timeline?: { + timeline?: { + instructions?: Array<{ + entries?: Array<{ + content?: { + itemContent?: { + tweet_results?: { + result?: GraphqlTweetResult; + }; + }; + }; + }>; + }>; + }; + }; + }; + errors?: Array<{ message: string }>; + }; + + const instructions = data.data?.bookmark_collection_timeline?.timeline?.instructions; + const pageTweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw }); + const nextCursor = extractCursorFromInstructions(instructions); + + if (data.errors && data.errors.length > 0) { + this.logBookmarksDebug('bookmark folder graphql errors (non-fatal)', { queryId, errors: data.errors }); + if (!instructions) { + lastError = data.errors.map((e) => e.message).join(', '); + continue; + } + } + + this.logBookmarksDebug('bookmark folder page parsed', { + queryId, + tweets: pageTweets.length, + hasNextCursor: Boolean(nextCursor), + }); + + return { success: true as const, tweets: pageTweets, cursor: nextCursor, had404 }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + this.logBookmarksDebug('bookmark folder request error', { queryId, error: lastError }); + } + } + + return { success: false as const, error: lastError ?? 'Unknown error fetching bookmark folder', had404 }; + }; + + let attempt = await tryOnce(buildVariables(pageCount, pageCursor, true)); + if (!attempt.success && attempt.error?.includes('Variable "$count"')) { + attempt = await tryOnce(buildVariables(pageCount, pageCursor, false)); } - if (secondAttempt.success) { - return { success: true, tweets: secondAttempt.tweets }; + + if (!attempt.success && attempt.error?.includes('Variable "$cursor"') && pageCursor) { + return { + success: false as const, + error: 'Bookmark folder pagination rejected the cursor parameter', + had404: attempt.had404, + }; } - return { success: false, error: secondAttempt.error }; + + return attempt; + }; + + const fetchWithRefresh = async (pageCount: number, pageCursor?: string) => { + const firstAttempt = await fetchPage(pageCount, pageCursor); + if (firstAttempt.success) { + return firstAttempt; + } + if (firstAttempt.had404) { + await this.refreshQueryIds(); + const secondAttempt = await fetchPage(pageCount, pageCursor); + if (secondAttempt.success) { + return secondAttempt; + } + return { success: false as const, error: secondAttempt.error }; + } + return { success: false as const, error: firstAttempt.error }; + }; + + const unlimited = !Number.isFinite(limit); + while (unlimited || tweets.length < limit) { + const pageCount = unlimited ? pageSize : Math.min(pageSize, limit - tweets.length); + const page = await fetchWithRefresh(pageCount, cursor); + if (!page.success) { + return { success: false, error: page.error }; + } + pagesFetched += 1; + + for (const tweet of page.tweets) { + if (seen.has(tweet.id)) { + continue; + } + seen.add(tweet.id); + tweets.push(tweet); + if (!unlimited && tweets.length >= limit) { + break; + } + } + + if (!page.cursor || page.cursor === cursor || page.tweets.length === 0) { + break; + } + if (maxPages && pagesFetched >= maxPages) { + break; + } + cursor = page.cursor; } - return { success: false, error: firstAttempt.error }; + return { success: true, tweets }; + } + + private async fetchWithRetry(url: string, init: RequestInit): Promise { + const maxRetries = 2; + const baseDelayMs = 500; + const retryable = new Set([429, 500, 502, 503, 504]); + + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + const response = await this.fetchWithTimeout(url, init); + if (!retryable.has(response.status) || attempt === maxRetries) { + return response; + } + this.logBookmarksDebug('retrying bookmarks request', { + status: response.status, + attempt, + }); + + // Retry-After supports delta-seconds only; HTTP-date falls back to backoff. + const retryAfter = response.headers.get('retry-after'); + const retryAfterMs = retryAfter ? Number.parseInt(retryAfter, 10) * 1000 : Number.NaN; + const backoffMs = Number.isFinite(retryAfterMs) + ? retryAfterMs + : baseDelayMs * 2 ** attempt + Math.floor(Math.random() * baseDelayMs); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); + } + + return this.fetchWithTimeout(url, init); } } diff --git a/tests/commands.bookmarks.test.ts b/tests/commands.bookmarks.test.ts new file mode 100644 index 0000000..2fe72b8 --- /dev/null +++ b/tests/commands.bookmarks.test.ts @@ -0,0 +1,41 @@ +import { Command } from 'commander'; +import { describe, expect, it, vi } from 'vitest'; +import type { CliContext } from '../src/cli/shared.js'; +import { registerBookmarksCommand } from '../src/commands/bookmarks.js'; + +describe('bookmarks command', () => { + it('requires --all when --max-pages is provided', async () => { + const program = new Command(); + const ctx = { + resolveTimeoutFromOptions: () => undefined, + resolveCredentialsFromOptions: async () => ({ + cookies: { authToken: 'auth', ct0: 'ct0', cookieHeader: 'auth=auth; ct0=ct0' }, + warnings: [], + }), + p: () => '', + printTweets: () => undefined, + } as unknown as CliContext; + + registerBookmarksCommand(program, ctx); + const command = program.commands.find((cmd) => cmd.name() === 'bookmarks'); + if (!command) { + throw new Error('bookmarks command not registered'); + } + + const action = (command as { _actionHandler: (opts: Record) => Promise })._actionHandler; + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(((code?: number) => { + throw new Error(`exit ${code}`); + }) as never); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + try { + await expect(action({ maxPages: '2' })).rejects.toThrow('exit 1'); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--max-pages requires --all')); + } finally { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); +}); diff --git a/tests/twitter-client.search-bookmarks.test.ts b/tests/twitter-client.search-bookmarks.test.ts index a2d1e27..7f90ef2 100644 --- a/tests/twitter-client.search-bookmarks.test.ts +++ b/tests/twitter-client.search-bookmarks.test.ts @@ -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', () => {