Add paged bookmark export with gating and retries
This commit is contained in:
@@ -20,3 +20,6 @@ bird
|
|||||||
*.tar
|
*.tar
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
*.tgz
|
*.tgz
|
||||||
|
bookmarks.json
|
||||||
|
bookmarks_canary.json
|
||||||
|
bookmarks_mid.json
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ bird mentions --user @steipete -n 5
|
|||||||
# Bookmarks
|
# Bookmarks
|
||||||
bird bookmarks -n 5
|
bird bookmarks -n 5
|
||||||
bird bookmarks --folder-id 123456789123456789 -n 5 # https://x.com/i/bookmarks/<folder-id>
|
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
|
# Likes
|
||||||
bird likes -n 5
|
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 thread <tweet-id-or-url> [--json]` — show the full conversation thread.
|
||||||
- `bird search "<query>" [-n count] [--json]` — search for tweets matching a query.
|
- `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 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 likes [-n count] [--json]` — list your liked tweets.
|
||||||
- `bird following [--user <userId>] [-n count] [--json]` — list users that you (or another user) follow.
|
- `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).
|
- `bird followers [--user <userId>] [-n count] [--json]` — list users that follow you (or another user).
|
||||||
|
|||||||
+61
-30
@@ -9,41 +9,72 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
|
|||||||
.description('Get your bookmarked tweets')
|
.description('Get your bookmarked tweets')
|
||||||
.option('-n, --count <number>', 'Number of bookmarks to fetch', '20')
|
.option('-n, --count <number>', 'Number of bookmarks to fetch', '20')
|
||||||
.option('--folder-id <id>', 'Bookmark folder (collection) id')
|
.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', 'Output as JSON')
|
||||||
.option('--json-full', 'Output as JSON with full raw API response in _raw field')
|
.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(
|
||||||
const opts = program.opts();
|
async (cmdOpts: {
|
||||||
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
|
count?: string;
|
||||||
const count = Number.parseInt(cmdOpts.count || '20', 10);
|
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) {
|
for (const warning of warnings) {
|
||||||
console.error(`${ctx.p('warn')}${warning}`);
|
console.error(`${ctx.p('warn')}${warning}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cookies.authToken || !cookies.ct0) {
|
if (!cookies.authToken || !cookies.ct0) {
|
||||||
console.error(`${ctx.p('err')}Missing required credentials`);
|
console.error(`${ctx.p('err')}Missing required credentials`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies, timeoutMs });
|
if (maxPages !== undefined && !cmdOpts.all) {
|
||||||
const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null;
|
console.error(`${ctx.p('err')}--max-pages requires --all.`);
|
||||||
if (cmdOpts.folderId && !folderId) {
|
process.exit(1);
|
||||||
console.error(`${ctx.p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/<id>.`);
|
}
|
||||||
process.exit(1);
|
if (!cmdOpts.all && (!Number.isFinite(count) || count <= 0)) {
|
||||||
}
|
console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`);
|
||||||
const includeRaw = cmdOpts.jsonFull ?? false;
|
process.exit(1);
|
||||||
const result = folderId
|
}
|
||||||
? await client.getBookmarkFolderTimeline(folderId, count, { includeRaw })
|
if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) {
|
||||||
: await client.getBookmarks(count, { includeRaw });
|
console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
if (result.success && result.tweets) {
|
const client = new TwitterClient({ cookies, timeoutMs });
|
||||||
const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
|
const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null;
|
||||||
ctx.printTweets(result.tweets, { json: cmdOpts.json || cmdOpts.jsonFull, emptyMessage });
|
if (cmdOpts.folderId && !folderId) {
|
||||||
} else {
|
console.error(`${ctx.p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/<id>.`);
|
||||||
console.error(`${ctx.p('err')}Failed to fetch bookmarks: ${result.error}`);
|
process.exit(1);
|
||||||
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+321
-129
@@ -1,8 +1,8 @@
|
|||||||
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
|
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
|
||||||
import { TWITTER_API_BASE } from './twitter-client-constants.js';
|
import { TWITTER_API_BASE } from './twitter-client-constants.js';
|
||||||
import { buildBookmarksFeatures, buildLikesFeatures } from './twitter-client-features.js';
|
import { buildBookmarksFeatures, buildLikesFeatures } from './twitter-client-features.js';
|
||||||
import type { GraphqlTweetResult, SearchResult } from './twitter-client-types.js';
|
import type { GraphqlTweetResult, SearchResult, TweetData } from './twitter-client-types.js';
|
||||||
import { parseTweetsFromInstructions } from './twitter-client-utils.js';
|
import { extractCursorFromInstructions, parseTweetsFromInstructions } from './twitter-client-utils.js';
|
||||||
|
|
||||||
/** Options for timeline fetch methods */
|
/** Options for timeline fetch methods */
|
||||||
export interface TimelineFetchOptions {
|
export interface TimelineFetchOptions {
|
||||||
@@ -10,10 +10,17 @@ export interface TimelineFetchOptions {
|
|||||||
includeRaw?: boolean;
|
includeRaw?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Options for paged timeline fetch methods */
|
||||||
|
export interface TimelinePaginationOptions extends TimelineFetchOptions {
|
||||||
|
maxPages?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TwitterClientTimelineMethods {
|
export interface TwitterClientTimelineMethods {
|
||||||
getBookmarks(count?: number, options?: TimelineFetchOptions): Promise<SearchResult>;
|
getBookmarks(count?: number, options?: TimelineFetchOptions): Promise<SearchResult>;
|
||||||
|
getAllBookmarks(options?: TimelinePaginationOptions): Promise<SearchResult>;
|
||||||
getLikes(count?: number, options?: TimelineFetchOptions): Promise<SearchResult>;
|
getLikes(count?: number, options?: TimelineFetchOptions): Promise<SearchResult>;
|
||||||
getBookmarkFolderTimeline(folderId: string, 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>>(
|
export function withTimelines<TBase extends AbstractConstructor<TwitterClientBase>>(
|
||||||
@@ -25,6 +32,17 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
|||||||
super(...args);
|
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[]> {
|
private async getBookmarksQueryIds(): Promise<string[]> {
|
||||||
const primary = await this.getQueryId('Bookmarks');
|
const primary = await this.getQueryId('Bookmarks');
|
||||||
return Array.from(new Set([primary, 'RV1g3b8n_SGOHwkqKYSCFw', 'tmd4ifV8RHltzn8ymGg1aw']));
|
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
|
* Get the authenticated user's bookmarks
|
||||||
*/
|
*/
|
||||||
async getBookmarks(count = 20, options: TimelineFetchOptions = {}): Promise<SearchResult> {
|
async getBookmarks(count = 20, options: TimelineFetchOptions = {}): Promise<SearchResult> {
|
||||||
const { includeRaw = false } = options;
|
return this.getBookmarksPaged(count, options);
|
||||||
const variables = {
|
}
|
||||||
count,
|
|
||||||
includePromotedContent: false,
|
|
||||||
withDownvotePerspective: false,
|
|
||||||
withReactionsMetadata: false,
|
|
||||||
withReactionsPerspective: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const features = buildBookmarksFeatures();
|
async getAllBookmarks(options?: TimelinePaginationOptions): Promise<SearchResult> {
|
||||||
|
return this.getBookmarksPaged(Number.POSITIVE_INFINITY, options);
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -256,24 +186,35 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
|||||||
count = 20,
|
count = 20,
|
||||||
options: TimelineFetchOptions = {},
|
options: TimelineFetchOptions = {},
|
||||||
): Promise<SearchResult> {
|
): Promise<SearchResult> {
|
||||||
const { includeRaw = false } = options;
|
return this.getBookmarkFolderTimelinePaged(folderId, count, options);
|
||||||
const variablesWithCount = {
|
}
|
||||||
bookmark_collection_id: folderId,
|
|
||||||
includePromotedContent: true,
|
|
||||||
count,
|
|
||||||
};
|
|
||||||
|
|
||||||
const variablesWithoutCount = {
|
async getAllBookmarkFolderTimeline(folderId: string, options?: TimelinePaginationOptions): Promise<SearchResult> {
|
||||||
bookmark_collection_id: folderId,
|
return this.getBookmarkFolderTimelinePaged(folderId, Number.POSITIVE_INFINITY, options);
|
||||||
includePromotedContent: true,
|
}
|
||||||
};
|
|
||||||
|
|
||||||
|
private async getBookmarksPaged(limit: number, options: TimelinePaginationOptions = {}): Promise<SearchResult> {
|
||||||
const features = buildBookmarksFeatures();
|
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 lastError: string | undefined;
|
||||||
let had404 = false;
|
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({
|
const params = new URLSearchParams({
|
||||||
variables: JSON.stringify(variables),
|
variables: JSON.stringify(variables),
|
||||||
@@ -281,10 +222,15 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const queryId of queryIds) {
|
for (const queryId of queryIds) {
|
||||||
const url = `${TWITTER_API_BASE}/${queryId}/BookmarkFolderTimeline?${params.toString()}`;
|
const url = `${TWITTER_API_BASE}/${queryId}/Bookmarks?${params.toString()}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.fetchWithTimeout(url, {
|
this.logBookmarksDebug('request bookmarks page', {
|
||||||
|
queryId,
|
||||||
|
pageCount,
|
||||||
|
hasCursor: Boolean(pageCursor),
|
||||||
|
});
|
||||||
|
const response = await this.fetchWithRetry(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: this.getHeaders(),
|
headers: this.getHeaders(),
|
||||||
});
|
});
|
||||||
@@ -292,17 +238,23 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
|||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
had404 = true;
|
had404 = true;
|
||||||
lastError = `HTTP ${response.status}`;
|
lastError = `HTTP ${response.status}`;
|
||||||
|
this.logBookmarksDebug('bookmarks 404', { queryId });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text();
|
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 };
|
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as {
|
const data = (await response.json()) as {
|
||||||
data?: {
|
data?: {
|
||||||
bookmark_collection_timeline?: {
|
bookmark_timeline_v2?: {
|
||||||
timeline?: {
|
timeline?: {
|
||||||
instructions?: Array<{
|
instructions?: Array<{
|
||||||
entries?: Array<{
|
entries?: Array<{
|
||||||
@@ -321,43 +273,283 @@ export function withTimelines<TBase extends AbstractConstructor<TwitterClientBas
|
|||||||
errors?: Array<{ message: string }>;
|
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) {
|
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;
|
this.logBookmarksDebug('bookmarks page parsed', {
|
||||||
const tweets = parseTweetsFromInstructions(instructions, { quoteDepth: this.quoteDepth, includeRaw });
|
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) {
|
} catch (error) {
|
||||||
lastError = error instanceof Error ? error.message : String(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);
|
const fetchWithRefresh = async (pageCount: number, pageCursor?: string) => {
|
||||||
if (!firstAttempt.success && firstAttempt.error?.includes('Variable "$count"')) {
|
const firstAttempt = await fetchPage(pageCount, pageCursor);
|
||||||
firstAttempt = await tryOnce(variablesWithoutCount);
|
if (firstAttempt.success) {
|
||||||
}
|
return firstAttempt;
|
||||||
if (firstAttempt.success) {
|
}
|
||||||
return { success: true, tweets: firstAttempt.tweets };
|
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) {
|
return { success: true, tweets };
|
||||||
await this.refreshQueryIds();
|
}
|
||||||
let secondAttempt = await tryOnce(variablesWithCount);
|
|
||||||
if (!secondAttempt.success && secondAttempt.error?.includes('Variable "$count"')) {
|
private async getBookmarkFolderTimelinePaged(
|
||||||
secondAttempt = await tryOnce(variablesWithoutCount);
|
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),
|
||||||
|
});
|
||||||
|
|
||||||
|
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<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);
|
const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string);
|
||||||
expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBe(true);
|
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', () => {
|
describe('TwitterClient bookmark folders', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user