chore: merge origin/main

This commit is contained in:
Peter Steinberger
2026-01-01 10:00:06 +01:00
8 changed files with 385 additions and 6 deletions
+13 -3
View File
@@ -17,6 +17,7 @@ import JSON5 from 'json5';
import kleur from 'kleur';
import { resolveCliInvocation } from './lib/cli-args.js';
import { type CookieSource, resolveCredentials } from './lib/cookies.js';
import { extractBookmarkFolderId } from './lib/extract-bookmark-folder-id.js';
import { extractTweetId } from './lib/extract-tweet-id.js';
import { mentionsQueryFromUserOption, normalizeHandle } from './lib/normalize-handle.js';
import {
@@ -715,8 +716,9 @@ program
.command('bookmarks')
.description('Get your bookmarked tweets')
.option('-n, --count <number>', 'Number of bookmarks to fetch', '20')
.option('--folder-id <id>', 'Bookmark folder (collection) id')
.option('--json', 'Output as JSON')
.action(async (cmdOpts: { count?: string; json?: boolean }) => {
.action(async (cmdOpts: { count?: string; json?: boolean; folderId?: string }) => {
const opts = program.opts();
const timeoutMs = resolveTimeoutFromOptions(opts);
const count = Number.parseInt(cmdOpts.count || '20', 10);
@@ -733,10 +735,18 @@ program
}
const client = new TwitterClient({ cookies, timeoutMs });
const result = await client.getBookmarks(count);
const folderId = cmdOpts.folderId ? extractBookmarkFolderId(cmdOpts.folderId) : null;
if (cmdOpts.folderId && !folderId) {
console.error(`${p('err')}Invalid --folder-id. Expected numeric ID or https://x.com/i/bookmarks/<id>.`);
process.exit(1);
}
const result = folderId
? await client.getBookmarkFolderTimeline(folderId, count)
: await client.getBookmarks(count);
if (result.success && result.tweets) {
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No bookmarks found.' });
const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage });
} else {
console.error(`${p('err')}Failed to fetch bookmarks: ${result.error}`);
process.exit(1);
+20
View File
@@ -0,0 +1,20 @@
/**
* Extract bookmark folder (collection) ID from an X/Twitter bookmarks URL.
*/
const BOOKMARK_FOLDER_URL_REGEX = /(?:twitter\.com|x\.com)\/i\/bookmarks\/(\d+)/i;
const BOOKMARK_FOLDER_ID_REGEX = /^\d{5,}$/;
export function extractBookmarkFolderId(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) {
return null;
}
const urlMatch = BOOKMARK_FOLDER_URL_REGEX.exec(trimmed);
if (urlMatch) {
return urlMatch[1];
}
if (BOOKMARK_FOLDER_ID_REGEX.test(trimmed)) {
return trimmed;
}
return null;
}
+113 -1
View File
@@ -29,6 +29,7 @@ const FALLBACK_QUERY_IDS = {
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw',
Likes: 'JR2gceKucIKcVNB_9JkhsA',
BookmarkFolderTimeline: 'KJIQpsvxrTfRIlbaRIySHQ',
} as const;
type OperationName = keyof typeof FALLBACK_QUERY_IDS;
@@ -1902,6 +1903,11 @@ export class TwitterClient {
return Array.from(new Set([primary, 'RV1g3b8n_SGOHwkqKYSCFw', 'tmd4ifV8RHltzn8ymGg1aw']));
}
private async getBookmarkFolderQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('BookmarkFolderTimeline');
return Array.from(new Set([primary, 'KJIQpsvxrTfRIlbaRIySHQ']));
}
/**
* Get the authenticated user's bookmarks
*/
@@ -1999,7 +2005,6 @@ export class TwitterClient {
return { success: false, error: firstAttempt.error };
}
private async getLikesQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('Likes');
return Array.from(new Set([primary, 'JR2gceKucIKcVNB_9JkhsA']));
@@ -2112,4 +2117,111 @@ export class TwitterClient {
return { success: false, error: firstAttempt.error };
}
/**
* Get the authenticated user's bookmark folder timeline
*/
async getBookmarkFolderTimeline(folderId: string, count = 20): Promise<SearchResult> {
const variablesWithCount = {
bookmark_collection_id: folderId,
includePromotedContent: true,
count,
};
const variablesWithoutCount = {
bookmark_collection_id: folderId,
includePromotedContent: true,
};
const features = this.buildBookmarksFeatures();
const tryOnce = async (variables: Record<string, unknown>) => {
let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getBookmarkFolderQueryIds();
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}`;
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_collection_timeline?: {
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_collection_timeline?.timeline?.instructions;
const tweets = this.parseTweetsFromInstructions(instructions);
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 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 };
}
if (firstAttempt.had404) {
await this.refreshQueryIds();
let secondAttempt = await tryOnce(variablesWithCount);
if (!secondAttempt.success && secondAttempt.error?.includes('Variable "$count"')) {
secondAttempt = await tryOnce(variablesWithoutCount);
}
if (secondAttempt.success) {
return { success: true, tweets: secondAttempt.tweets };
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error };
}
}