Add paged bookmark export with gating and retries
This commit is contained in:
@@ -20,3 +20,6 @@ bird
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.tgz
|
||||
bookmarks.json
|
||||
bookmarks_canary.json
|
||||
bookmarks_mid.json
|
||||
|
||||
@@ -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/<folder-id>
|
||||
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 <tweet-id-or-url> [--json]` — show the full conversation thread.
|
||||
- `bird search "<query>" [-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 <userId>] [-n count] [--json]` — list users that you (or another user) follow.
|
||||
- `bird followers [--user <userId>] [-n count] [--json]` — list users that follow you (or another user).
|
||||
|
||||
@@ -9,12 +9,23 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
|
||||
.description('Get your bookmarked tweets')
|
||||
.option('-n, --count <number>', 'Number of bookmarks to fetch', '20')
|
||||
.option('--folder-id <id>', 'Bookmark folder (collection) id')
|
||||
.option('--all', 'Fetch all bookmarks (paged)')
|
||||
.option('--max-pages <number>', '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 }) => {
|
||||
.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);
|
||||
|
||||
@@ -27,6 +38,19 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const client = new TwitterClient({ cookies, timeoutMs });
|
||||
const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null;
|
||||
if (cmdOpts.folderId && !folderId) {
|
||||
@@ -34,9 +58,15 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
|
||||
process.exit(1);
|
||||
}
|
||||
const includeRaw = cmdOpts.jsonFull ?? false;
|
||||
const timelineOptions = { includeRaw };
|
||||
const paginationOptions = { includeRaw, maxPages };
|
||||
const result = folderId
|
||||
? await client.getBookmarkFolderTimeline(folderId, count, { includeRaw })
|
||||
: await client.getBookmarks(count, { includeRaw });
|
||||
? 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.';
|
||||
@@ -45,5 +75,6 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
|
||||
console.error(`${ctx.p('err')}Failed to fetch bookmarks: ${result.error}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
+314
-122
@@ -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<SearchResult>;
|
||||
getAllBookmarks(options?: TimelinePaginationOptions): Promise<SearchResult>;
|
||||
getLikes(count?: number, options?: TimelineFetchOptions): Promise<SearchResult>;
|
||||
getBookmarkFolderTimeline(folderId: string, count?: number, options?: TimelineFetchOptions): Promise<SearchResult>;
|
||||
getAllBookmarkFolderTimeline(folderId: string, options?: TimelinePaginationOptions): Promise<SearchResult>;
|
||||
}
|
||||
|
||||
export function withTimelines<TBase extends AbstractConstructor<TwitterClientBase>>(
|
||||
@@ -25,6 +32,17 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
||||
super(...args);
|
||||
}
|
||||
|
||||
private logBookmarksDebug(message: string, data?: Record<string, unknown>): 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<string[]> {
|
||||
const primary = await this.getQueryId('Bookmarks');
|
||||
return Array.from(new Set([primary, 'RV1g3b8n_SGOHwkqKYSCFw', 'tmd4ifV8RHltzn8ymGg1aw']));
|
||||
@@ -44,99 +62,11 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
||||
* Get the authenticated user's bookmarks
|
||||
*/
|
||||
async getBookmarks(count = 20, options: TimelineFetchOptions = {}): Promise<SearchResult> {
|
||||
const { includeRaw = false } = options;
|
||||
const variables = {
|
||||
count,
|
||||
includePromotedContent: false,
|
||||
withDownvotePerspective: false,
|
||||
withReactionsMetadata: false,
|
||||
withReactionsPerspective: false,
|
||||
};
|
||||
|
||||
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;
|
||||
return this.getBookmarksPaged(count, options);
|
||||
}
|
||||
|
||||
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<SearchResult> {
|
||||
return this.getBookmarksPaged(Number.POSITIVE_INFINITY, options);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,25 +186,195 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
||||
count = 20,
|
||||
options: TimelineFetchOptions = {},
|
||||
): Promise<SearchResult> {
|
||||
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<SearchResult> {
|
||||
return this.getBookmarkFolderTimelinePaged(folderId, Number.POSITIVE_INFINITY, options);
|
||||
}
|
||||
|
||||
private async getBookmarksPaged(limit: number, options: TimelinePaginationOptions = {}): Promise<SearchResult> {
|
||||
const features = buildBookmarksFeatures();
|
||||
const pageSize = 20;
|
||||
const seen = new Set<string>();
|
||||
const tweets: TweetData[] = [];
|
||||
let cursor: string | undefined;
|
||||
let pagesFetched = 0;
|
||||
const { includeRaw = false, maxPages } = options;
|
||||
|
||||
const tryOnce = async (variables: Record<string, unknown>) => {
|
||||
const fetchPage = async (pageCount: number, pageCursor?: string) => {
|
||||
let lastError: string | undefined;
|
||||
let had404 = false;
|
||||
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),
|
||||
features: JSON.stringify(features),
|
||||
});
|
||||
|
||||
for (const queryId of queryIds) {
|
||||
const url = `${TWITTER_API_BASE}/${queryId}/Bookmarks?${params.toString()}`;
|
||||
|
||||
try {
|
||||
this.logBookmarksDebug('request bookmarks page', {
|
||||
queryId,
|
||||
pageCount,
|
||||
hasCursor: Boolean(pageCursor),
|
||||
});
|
||||
const response = await this.fetchWithRetry(url, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders(),
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
had404 = true;
|
||||
lastError = `HTTP ${response.status}`;
|
||||
this.logBookmarksDebug('bookmarks 404', { queryId });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
this.logBookmarksDebug('bookmarks 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_timeline_v2?: {
|
||||
timeline?: {
|
||||
instructions?: Array<{
|
||||
entries?: Array<{
|
||||
content?: {
|
||||
itemContent?: {
|
||||
tweet_results?: {
|
||||
result?: GraphqlTweetResult;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
|
||||
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) {
|
||||
this.logBookmarksDebug('bookmarks graphql errors (non-fatal)', { queryId, errors: data.errors });
|
||||
if (!instructions) {
|
||||
lastError = data.errors.map((e) => e.message).join(', ');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
this.logBookmarksDebug('bookmarks 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('bookmarks request error', { queryId, error: lastError });
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false as const, error: lastError ?? 'Unknown error fetching bookmarks', had404 };
|
||||
};
|
||||
|
||||
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: true, tweets };
|
||||
}
|
||||
|
||||
private async getBookmarkFolderTimelinePaged(
|
||||
folderId: string,
|
||||
limit: number,
|
||||
options: TimelinePaginationOptions = {},
|
||||
): Promise<SearchResult> {
|
||||
const features = buildBookmarksFeatures();
|
||||
const pageSize = 20;
|
||||
const seen = new Set<string>();
|
||||
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<string, unknown>) => {
|
||||
const params = new URLSearchParams({
|
||||
variables: JSON.stringify(variables),
|
||||
features: JSON.stringify(features),
|
||||
@@ -284,7 +384,13 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
||||
const url = `${TWITTER_API_BASE}/${queryId}/BookmarkFolderTimeline?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
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(),
|
||||
});
|
||||
@@ -292,11 +398,17 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -321,43 +433,123 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
||||
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) {
|
||||
return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
|
||||
this.logBookmarksDebug('bookmark folder 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('bookmark folder 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('bookmark folder request error', { queryId, error: lastError });
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false as const, error: lastError ?? 'Unknown error fetching bookmark folder', 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 };
|
||||
let attempt = await tryOnce(buildVariables(pageCount, pageCursor, true));
|
||||
if (!attempt.success && attempt.error?.includes('Variable "$count"')) {
|
||||
attempt = await tryOnce(buildVariables(pageCount, pageCursor, false));
|
||||
}
|
||||
|
||||
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 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();
|
||||
let secondAttempt = await tryOnce(variablesWithCount);
|
||||
if (!secondAttempt.success && secondAttempt.error?.includes('Variable "$count"')) {
|
||||
secondAttempt = await tryOnce(variablesWithoutCount);
|
||||
}
|
||||
const secondAttempt = await fetchPage(pageCount, pageCursor);
|
||||
if (secondAttempt.success) {
|
||||
return { success: true, tweets: secondAttempt.tweets };
|
||||
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;
|
||||
}
|
||||
return { success: false, error: secondAttempt.error };
|
||||
}
|
||||
|
||||
return { success: false, error: firstAttempt.error };
|
||||
if (!page.cursor || page.cursor === cursor || page.tweets.length === 0) {
|
||||
break;
|
||||
}
|
||||
if (maxPages && pagesFetched >= maxPages) {
|
||||
break;
|
||||
}
|
||||
cursor = page.cursor;
|
||||
}
|
||||
|
||||
return { success: true, tweets };
|
||||
}
|
||||
|
||||
private async fetchWithRetry(url: string, init: RequestInit): Promise<Response> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, string>) => Promise<void> })._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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user