feat: add unbookmark command to remove bookmarks

This commit is contained in:
Mariano Belinky
2026-01-05 02:10:10 +00:00
committed by Peter Steinberger
parent 1d1ed6b515
commit 04a0c98362
9 changed files with 145 additions and 1 deletions
+3
View File
@@ -53,6 +53,8 @@ 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 --json
bird bookmarks --all --max-pages 2 --json bird bookmarks --all --max-pages 2 --json
bird unbookmark 1234567890123456789
bird unbookmark https://x.com/user/status/1234567890123456789
# Likes # Likes
bird likes -n 5 bird likes -n 5
@@ -94,6 +96,7 @@ const result = await client.search('from:steipete', 50);
- `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] [--all] [--max-pages n] [--json]` — list your bookmarked tweets (or a specific bookmark folder); `--max-pages` requires `--all`. - `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 unbookmark <tweet-id-or-url...>` — remove one or more bookmarks by tweet ID or URL.
- `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).
+1
View File
@@ -11,6 +11,7 @@ const TARGET_OPERATIONS = [
'CreateTweet', 'CreateTweet',
'CreateRetweet', 'CreateRetweet',
'FavoriteTweet', 'FavoriteTweet',
'DeleteBookmark',
'TweetDetail', 'TweetDetail',
'SearchTimeline', 'SearchTimeline',
'Bookmarks', 'Bookmarks',
+3
View File
@@ -7,6 +7,7 @@ import { registerPostCommands } from '../commands/post.js';
import { registerQueryIdsCommand } from '../commands/query-ids.js'; import { registerQueryIdsCommand } from '../commands/query-ids.js';
import { registerReadCommands } from '../commands/read.js'; import { registerReadCommands } from '../commands/read.js';
import { registerSearchCommands } from '../commands/search.js'; import { registerSearchCommands } from '../commands/search.js';
import { registerUnbookmarkCommand } from '../commands/unbookmark.js';
import { registerUserCommands } from '../commands/users.js'; import { registerUserCommands } from '../commands/users.js';
import { getCliVersion } from '../lib/version.js'; import { getCliVersion } from '../lib/version.js';
import { type CliContext, collectCookieSource } from './shared.js'; import { type CliContext, collectCookieSource } from './shared.js';
@@ -21,6 +22,7 @@ export const KNOWN_COMMANDS = new Set([
'search', 'search',
'mentions', 'mentions',
'bookmarks', 'bookmarks',
'unbookmark',
'following', 'following',
'followers', 'followers',
'likes', 'likes',
@@ -127,6 +129,7 @@ export function createProgram(ctx: CliContext): Command {
registerReadCommands(program, ctx); registerReadCommands(program, ctx);
registerSearchCommands(program, ctx); registerSearchCommands(program, ctx);
registerBookmarksCommand(program, ctx); registerBookmarksCommand(program, ctx);
registerUnbookmarkCommand(program, ctx);
registerListsCommand(program, ctx); registerListsCommand(program, ctx);
registerUserCommands(program, ctx); registerUserCommands(program, ctx);
registerCheckCommand(program, ctx); registerCheckCommand(program, ctx);
+43
View File
@@ -0,0 +1,43 @@
import type { Command } from 'commander';
import type { CliContext } from '../cli/shared.js';
import { TwitterClient } from '../lib/twitter-client.js';
export function registerUnbookmarkCommand(program: Command, ctx: CliContext): void {
program
.command('unbookmark')
.description('Remove bookmarked tweets')
.argument('<tweet-id-or-url...>', 'Tweet IDs or URLs to remove from bookmarks')
.action(async (tweetIdOrUrls: string[]) => {
const opts = program.opts();
const timeoutMs = ctx.resolveTimeoutFromOptions(opts);
const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts);
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);
}
const client = new TwitterClient({ cookies, timeoutMs });
let failures = 0;
for (const input of tweetIdOrUrls) {
const tweetId = ctx.extractTweetId(input);
const result = await client.unbookmark(tweetId);
if (result.success) {
console.log(`${ctx.p('ok')}Removed bookmark for ${tweetId}`);
} else {
failures += 1;
console.error(`${ctx.p('err')}Failed to remove bookmark for ${tweetId}: ${result.error}`);
}
}
if (failures > 0) {
process.exit(1);
}
});
}
+1
View File
@@ -2,6 +2,7 @@
"CreateTweet": "Uf3io9zVp1DsYxrmL5FJ7g", "CreateTweet": "Uf3io9zVp1DsYxrmL5FJ7g",
"CreateRetweet": "LFho5rIi4xcKO90p9jwG7A", "CreateRetweet": "LFho5rIi4xcKO90p9jwG7A",
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A", "FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
"DeleteBookmark": "Wlmlj2-xzyS1GN3a6cj-mQ",
"TweetDetail": "97JF30KziU00483E_8elBA", "TweetDetail": "97JF30KziU00483E_8elBA",
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ", "SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ",
"Bookmarks": "RV1g3b8n_SGOHwkqKYSCFw", "Bookmarks": "RV1g3b8n_SGOHwkqKYSCFw",
+80
View File
@@ -0,0 +1,80 @@
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE, TWITTER_GRAPHQL_POST_URL } from './twitter-client-constants.js';
import type { BookmarkMutationResult } from './twitter-client-types.js';
export interface TwitterClientBookmarkMethods {
unbookmark(tweetId: string): Promise<BookmarkMutationResult>;
}
export function withBookmarks<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientBookmarkMethods> {
abstract class TwitterClientBookmarks extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
async unbookmark(tweetId: string): Promise<BookmarkMutationResult> {
const variables = { tweet_id: tweetId };
let queryId = await this.getQueryId('DeleteBookmark');
let urlWithOperation = `${TWITTER_API_BASE}/${queryId}/DeleteBookmark`;
const buildBody = () => JSON.stringify({ variables, queryId });
let body = buildBody();
const headers = { ...this.getHeaders(), referer: `https://x.com/i/status/${tweetId}` };
const parseResponse = async (response: Response): Promise<BookmarkMutationResult> => {
if (!response.ok) {
const text = await response.text();
return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` };
}
const data = (await response.json()) as { errors?: Array<{ message: string }> };
if (data.errors && data.errors.length > 0) {
return { success: false, error: data.errors.map((e) => e.message).join(', ') };
}
return { success: true };
};
try {
let response = await this.fetchWithTimeout(urlWithOperation, {
method: 'POST',
headers,
body,
});
if (response.status === 404) {
await this.refreshQueryIds();
queryId = await this.getQueryId('DeleteBookmark');
urlWithOperation = `${TWITTER_API_BASE}/${queryId}/DeleteBookmark`;
body = buildBody();
response = await this.fetchWithTimeout(urlWithOperation, {
method: 'POST',
headers,
body,
});
if (response.status === 404) {
const retry = await this.fetchWithTimeout(TWITTER_GRAPHQL_POST_URL, {
method: 'POST',
headers,
body,
});
return parseResponse(retry);
}
}
return parseResponse(response);
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
}
return TwitterClientBookmarks;
}
+1
View File
@@ -17,6 +17,7 @@ export const FALLBACK_QUERY_IDS = {
CreateTweet: 'TAJw1rBsjAtdNgTdlo2oeg', CreateTweet: 'TAJw1rBsjAtdNgTdlo2oeg',
CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA', CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA',
FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A', FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A',
DeleteBookmark: 'Wlmlj2-xzyS1GN3a6cj-mQ',
TweetDetail: '97JF30KziU00483E_8elBA', TweetDetail: '97JF30KziU00483E_8elBA',
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ', SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg', UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
+9
View File
@@ -191,6 +191,15 @@ export type TweetResult =
error: string; error: string;
}; };
export type BookmarkMutationResult =
| {
success: true;
}
| {
success: false;
error: string;
};
export interface UploadMediaResult { export interface UploadMediaResult {
success: boolean; success: boolean;
mediaId?: string; mediaId?: string;
+4 -1
View File
@@ -1,5 +1,6 @@
import type { AbstractConstructor } from './twitter-client-base.js'; import type { AbstractConstructor } from './twitter-client-base.js';
import { TwitterClientBase } from './twitter-client-base.js'; import { TwitterClientBase } from './twitter-client-base.js';
import { type TwitterClientBookmarkMethods, withBookmarks } from './twitter-client-bookmarks.js';
import { type TwitterClientListMethods, withLists } from './twitter-client-lists.js'; import { type TwitterClientListMethods, withLists } from './twitter-client-lists.js';
import { type TwitterClientMediaMethods, withMedia } from './twitter-client-media.js'; import { type TwitterClientMediaMethods, withMedia } from './twitter-client-media.js';
import { type TwitterClientPostingMethods, withPosting } from './twitter-client-posting.js'; import { type TwitterClientPostingMethods, withPosting } from './twitter-client-posting.js';
@@ -9,6 +10,7 @@ import { type TwitterClientTweetDetailMethods, withTweetDetails } from './twitte
import { type TwitterClientUserMethods, withUsers } from './twitter-client-users.js'; import { type TwitterClientUserMethods, withUsers } from './twitter-client-users.js';
type TwitterClientInstance = TwitterClientBase & type TwitterClientInstance = TwitterClientBase &
TwitterClientBookmarkMethods &
TwitterClientListMethods & TwitterClientListMethods &
TwitterClientMediaMethods & TwitterClientMediaMethods &
TwitterClientPostingMethods & TwitterClientPostingMethods &
@@ -18,12 +20,13 @@ type TwitterClientInstance = TwitterClientBase &
TwitterClientUserMethods; TwitterClientUserMethods;
const MixedTwitterClient = withUsers( const MixedTwitterClient = withUsers(
withLists(withTimelines(withSearch(withTweetDetails(withPosting(withMedia(TwitterClientBase)))))), withLists(withTimelines(withSearch(withTweetDetails(withPosting(withBookmarks(withMedia(TwitterClientBase))))))),
) as AbstractConstructor<TwitterClientInstance>; ) as AbstractConstructor<TwitterClientInstance>;
export class TwitterClient extends MixedTwitterClient {} export class TwitterClient extends MixedTwitterClient {}
export type { export type {
BookmarkMutationResult,
CurrentUserResult, CurrentUserResult,
FollowingResult, FollowingResult,
GetTweetResult, GetTweetResult,