diff --git a/README.md b/README.md index 9c7b7f6..658de3f 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ bird bookmarks -n 5 bird bookmarks --folder-id 123456789123456789 -n 5 # https://x.com/i/bookmarks/ bird bookmarks --all --json bird bookmarks --all --max-pages 2 --json +bird unbookmark 1234567890123456789 +bird unbookmark https://x.com/user/status/1234567890123456789 # Likes bird likes -n 5 @@ -94,6 +96,7 @@ const result = await client.search('from:steipete', 50); - `bird search "" [-n count] [--json]` — search for tweets matching a query. - `bird mentions [-n count] [--user @handle] [--json]` — find tweets mentioning a user (defaults to the authenticated user). - `bird bookmarks [-n count] [--folder-id id] [--all] [--max-pages n] [--json]` — list your bookmarked tweets (or a specific bookmark folder); `--max-pages` requires `--all`. +- `bird unbookmark ` — remove one or more bookmarks by tweet ID or URL. - `bird likes [-n count] [--json]` — list your liked tweets. - `bird following [--user ] [-n count] [--json]` — list users that you (or another user) follow. - `bird followers [--user ] [-n count] [--json]` — list users that follow you (or another user). diff --git a/scripts/update-query-ids.ts b/scripts/update-query-ids.ts index f9a0415..baab967 100644 --- a/scripts/update-query-ids.ts +++ b/scripts/update-query-ids.ts @@ -11,6 +11,7 @@ const TARGET_OPERATIONS = [ 'CreateTweet', 'CreateRetweet', 'FavoriteTweet', + 'DeleteBookmark', 'TweetDetail', 'SearchTimeline', 'Bookmarks', diff --git a/src/cli/program.ts b/src/cli/program.ts index 86f4396..584032d 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -7,6 +7,7 @@ import { registerPostCommands } from '../commands/post.js'; import { registerQueryIdsCommand } from '../commands/query-ids.js'; import { registerReadCommands } from '../commands/read.js'; import { registerSearchCommands } from '../commands/search.js'; +import { registerUnbookmarkCommand } from '../commands/unbookmark.js'; import { registerUserCommands } from '../commands/users.js'; import { getCliVersion } from '../lib/version.js'; import { type CliContext, collectCookieSource } from './shared.js'; @@ -21,6 +22,7 @@ export const KNOWN_COMMANDS = new Set([ 'search', 'mentions', 'bookmarks', + 'unbookmark', 'following', 'followers', 'likes', @@ -127,6 +129,7 @@ export function createProgram(ctx: CliContext): Command { registerReadCommands(program, ctx); registerSearchCommands(program, ctx); registerBookmarksCommand(program, ctx); + registerUnbookmarkCommand(program, ctx); registerListsCommand(program, ctx); registerUserCommands(program, ctx); registerCheckCommand(program, ctx); diff --git a/src/commands/unbookmark.ts b/src/commands/unbookmark.ts new file mode 100644 index 0000000..caef70e --- /dev/null +++ b/src/commands/unbookmark.ts @@ -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 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); + } + }); +} diff --git a/src/lib/query-ids.json b/src/lib/query-ids.json index 0cf863e..a6f3e2c 100644 --- a/src/lib/query-ids.json +++ b/src/lib/query-ids.json @@ -2,6 +2,7 @@ "CreateTweet": "Uf3io9zVp1DsYxrmL5FJ7g", "CreateRetweet": "LFho5rIi4xcKO90p9jwG7A", "FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A", + "DeleteBookmark": "Wlmlj2-xzyS1GN3a6cj-mQ", "TweetDetail": "97JF30KziU00483E_8elBA", "SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ", "Bookmarks": "RV1g3b8n_SGOHwkqKYSCFw", diff --git a/src/lib/twitter-client-bookmarks.ts b/src/lib/twitter-client-bookmarks.ts new file mode 100644 index 0000000..81e1416 --- /dev/null +++ b/src/lib/twitter-client-bookmarks.ts @@ -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; +} + +export function withBookmarks>( + Base: TBase, +): Mixin { + 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 { + 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 => { + 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; +} diff --git a/src/lib/twitter-client-constants.ts b/src/lib/twitter-client-constants.ts index fdcdf3d..23c4dd2 100644 --- a/src/lib/twitter-client-constants.ts +++ b/src/lib/twitter-client-constants.ts @@ -17,6 +17,7 @@ export const FALLBACK_QUERY_IDS = { CreateTweet: 'TAJw1rBsjAtdNgTdlo2oeg', CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA', FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A', + DeleteBookmark: 'Wlmlj2-xzyS1GN3a6cj-mQ', TweetDetail: '97JF30KziU00483E_8elBA', SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ', UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg', diff --git a/src/lib/twitter-client-types.ts b/src/lib/twitter-client-types.ts index eb9da7f..67d4768 100644 --- a/src/lib/twitter-client-types.ts +++ b/src/lib/twitter-client-types.ts @@ -191,6 +191,15 @@ export type TweetResult = error: string; }; +export type BookmarkMutationResult = + | { + success: true; + } + | { + success: false; + error: string; + }; + export interface UploadMediaResult { success: boolean; mediaId?: string; diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index 3178499..9b1bc20 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -1,5 +1,6 @@ import type { AbstractConstructor } 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 TwitterClientMediaMethods, withMedia } from './twitter-client-media.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'; type TwitterClientInstance = TwitterClientBase & + TwitterClientBookmarkMethods & TwitterClientListMethods & TwitterClientMediaMethods & TwitterClientPostingMethods & @@ -18,12 +20,13 @@ type TwitterClientInstance = TwitterClientBase & TwitterClientUserMethods; const MixedTwitterClient = withUsers( - withLists(withTimelines(withSearch(withTweetDetails(withPosting(withMedia(TwitterClientBase)))))), + withLists(withTimelines(withSearch(withTweetDetails(withPosting(withBookmarks(withMedia(TwitterClientBase))))))), ) as AbstractConstructor; export class TwitterClient extends MixedTwitterClient {} export type { + BookmarkMutationResult, CurrentUserResult, FollowingResult, GetTweetResult,