Merge pull request #7 from tylerseymour/bookmark-collection-selection
feat: add folder support for bookmarks command
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
|
||||
### Added
|
||||
- `bookmarks` command to list your bookmarked tweets.
|
||||
- `bookmarks --folder-id` to fetch bookmark folders (thanks @tylerseymour).
|
||||
|
||||
### Changed
|
||||
- Cookie extraction now uses `@steipete/sweet-cookie` (drops `sqlite3` CLI + custom browser readers in `bird`).
|
||||
|
||||
@@ -48,8 +48,9 @@ bird search "from:steipete" -n 5
|
||||
bird mentions -n 5
|
||||
bird mentions --user @steipete -n 5
|
||||
|
||||
# Bookmarks
|
||||
# Bookmarks
|
||||
bird bookmarks -n 5
|
||||
bird bookmarks --folder-id 123456789123456789 -n 5 # https://x.com/i/bookmarks/<folder-id>
|
||||
|
||||
# Refresh GraphQL query IDs cache (no rebuild)
|
||||
bird query-ids --fresh
|
||||
@@ -67,7 +68,7 @@ bird query-ids --fresh
|
||||
- `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] [--json]` — list your bookmarked tweets.
|
||||
- `bird bookmarks [-n count] [--folder-id id] [--json]` — list your bookmarked tweets (or a specific bookmark folder).
|
||||
- `bird whoami` — print which Twitter account your cookies belong to.
|
||||
- `bird check` — show which credentials are available and where they were sourced from.
|
||||
|
||||
|
||||
+13
-3
@@ -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 {
|
||||
@@ -713,8 +714,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);
|
||||
@@ -731,10 +733,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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -28,6 +28,7 @@ const FALLBACK_QUERY_IDS = {
|
||||
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
|
||||
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
|
||||
Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw',
|
||||
BookmarkFolderTimeline: 'KJIQpsvxrTfRIlbaRIySHQ',
|
||||
} as const;
|
||||
|
||||
type OperationName = keyof typeof FALLBACK_QUERY_IDS;
|
||||
@@ -1890,6 +1891,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
|
||||
*/
|
||||
@@ -1987,4 +1993,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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { extractBookmarkFolderId } from '../src/lib/extract-bookmark-folder-id.js';
|
||||
import { extractTweetId } from '../src/lib/extract-tweet-id.js';
|
||||
|
||||
describe('CLI utilities', () => {
|
||||
@@ -29,4 +30,30 @@ describe('CLI utilities', () => {
|
||||
expect(extractTweetId(url)).toBe('1234567890123456789');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBookmarkFolderId', () => {
|
||||
it('should extract ID from x.com bookmarks URL', () => {
|
||||
const url = 'https://x.com/i/bookmarks/1976792203235119344';
|
||||
expect(extractBookmarkFolderId(url)).toBe('1976792203235119344');
|
||||
});
|
||||
|
||||
it('should extract ID from twitter.com bookmarks URL', () => {
|
||||
const url = 'https://twitter.com/i/bookmarks/1976792203235119344';
|
||||
expect(extractBookmarkFolderId(url)).toBe('1976792203235119344');
|
||||
});
|
||||
|
||||
it('should accept a numeric ID as-is', () => {
|
||||
const id = '1976792203235119344';
|
||||
expect(extractBookmarkFolderId(id)).toBe('1976792203235119344');
|
||||
});
|
||||
|
||||
it('should return null for invalid values', () => {
|
||||
expect(extractBookmarkFolderId('not-an-id')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for folder_id query URLs', () => {
|
||||
const url = 'https://x.com/i/bookmarks?folder_id=1976792203235119344';
|
||||
expect(extractBookmarkFolderId(url)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ type ResponseLike = {
|
||||
|
||||
type TwitterClientApiPrivate = TwitterClient & {
|
||||
getBookmarksQueryIds: () => Promise<string[]>;
|
||||
getBookmarkFolderQueryIds: () => Promise<string[]>;
|
||||
};
|
||||
|
||||
const makeResponse = (overrides: Partial<ResponseLike> = {}): ResponseLike => ({
|
||||
@@ -204,4 +205,67 @@ describe('TwitterClient API coverage', () => {
|
||||
expect(result.error).toBe('HTTP 404');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bookmark folder error paths', () => {
|
||||
it('returns an error for non-ok responses', async () => {
|
||||
const mockFetch = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(makeResponse({ ok: false, status: 500, text: async () => 'down' }));
|
||||
global.fetch = mockFetch as unknown as typeof fetch;
|
||||
|
||||
const client = new TwitterClient({ cookies: validCookies });
|
||||
const result = await client.getBookmarkFolderTimeline('123', 1);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('HTTP 500');
|
||||
});
|
||||
|
||||
it('returns API errors from payloads', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
json: async () => ({ errors: [{ message: 'bad' }] }),
|
||||
}),
|
||||
);
|
||||
global.fetch = mockFetch as unknown as typeof fetch;
|
||||
|
||||
const client = new TwitterClient({ cookies: validCookies });
|
||||
const result = await client.getBookmarkFolderTimeline('123', 1);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('bad');
|
||||
});
|
||||
|
||||
it('returns an error when fetching throws', async () => {
|
||||
const mockFetch = vi.fn().mockRejectedValue(new Error('boom'));
|
||||
global.fetch = mockFetch as unknown as typeof fetch;
|
||||
|
||||
const client = new TwitterClient({ cookies: validCookies });
|
||||
const result = await client.getBookmarkFolderTimeline('123', 1);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('boom');
|
||||
});
|
||||
|
||||
it('returns unknown error when no query ids are available', async () => {
|
||||
const client = new TwitterClient({ cookies: validCookies });
|
||||
const clientPrivate = client as unknown as TwitterClientApiPrivate;
|
||||
clientPrivate.getBookmarkFolderQueryIds = async () => [];
|
||||
|
||||
const result = await client.getBookmarkFolderTimeline('123', 1);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Unknown error fetching bookmark folder');
|
||||
});
|
||||
|
||||
it('returns the second attempt error after 404s', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(makeResponse({ ok: false, status: 404, text: async () => 'nope' }));
|
||||
global.fetch = mockFetch as unknown as typeof fetch;
|
||||
|
||||
const client = new TwitterClient({ cookies: validCookies });
|
||||
const result = await client.getBookmarkFolderTimeline('123', 1);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('HTTP 404');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1144,6 +1144,154 @@ describe('TwitterClient', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('bookmark folders', () => {
|
||||
let mockFetch: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
it('fetches bookmark folder timeline and parses tweet results', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
data: {
|
||||
bookmark_collection_timeline: {
|
||||
timeline: {
|
||||
instructions: [
|
||||
{
|
||||
entries: [
|
||||
{
|
||||
content: {
|
||||
itemContent: {
|
||||
tweet_results: {
|
||||
result: {
|
||||
rest_id: '9',
|
||||
legacy: {
|
||||
full_text: 'saved in folder',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
reply_count: 0,
|
||||
retweet_count: 0,
|
||||
favorite_count: 0,
|
||||
conversation_id_str: '9',
|
||||
},
|
||||
core: {
|
||||
user_results: {
|
||||
result: {
|
||||
rest_id: 'u9',
|
||||
legacy: { screen_name: 'folder', name: 'Folder' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const client = new TwitterClient({ cookies: validCookies });
|
||||
const result = await client.getBookmarkFolderTimeline('123', 2);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.tweets?.[0].id).toBe('9');
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(options.method).toBe('GET');
|
||||
expect(String(url)).toContain('/BookmarkFolderTimeline?');
|
||||
const parsedVars = JSON.parse(new URL(url as string).searchParams.get('variables') as string);
|
||||
expect(parsedVars.bookmark_collection_id).toBe('123');
|
||||
expect(parsedVars.count).toBe(2);
|
||||
const parsedFeatures = JSON.parse(new URL(url as string).searchParams.get('features') as string);
|
||||
expect(parsedFeatures.graphql_timeline_v2_bookmark_timeline).toBe(true);
|
||||
});
|
||||
|
||||
it('retries without count when API rejects the count variable', async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
errors: [{ message: 'Variable "$count" is not defined by operation' }],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
data: {
|
||||
bookmark_collection_timeline: {
|
||||
timeline: {
|
||||
instructions: [
|
||||
{
|
||||
entries: [
|
||||
{
|
||||
content: {
|
||||
itemContent: {
|
||||
tweet_results: {
|
||||
result: {
|
||||
rest_id: '9',
|
||||
legacy: {
|
||||
full_text: 'saved in folder',
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
reply_count: 0,
|
||||
retweet_count: 0,
|
||||
favorite_count: 0,
|
||||
conversation_id_str: '9',
|
||||
},
|
||||
core: {
|
||||
user_results: {
|
||||
result: {
|
||||
rest_id: 'u9',
|
||||
legacy: { screen_name: 'folder', name: 'Folder' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const client = new TwitterClient({ cookies: validCookies });
|
||||
const clientPrivate = client as TwitterClient & { getBookmarkFolderQueryIds: () => Promise<string[]> };
|
||||
clientPrivate.getBookmarkFolderQueryIds = async () => ['test'];
|
||||
|
||||
const result = await client.getBookmarkFolderTimeline('123', 2);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
const firstVars = JSON.parse(
|
||||
new URL(mockFetch.mock.calls[0][0] as string).searchParams.get('variables') as string,
|
||||
);
|
||||
const secondVars = JSON.parse(
|
||||
new URL(mockFetch.mock.calls[1][0] as string).searchParams.get('variables') as string,
|
||||
);
|
||||
|
||||
expect(firstVars.count).toBe(2);
|
||||
expect(secondVars.count).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('conversation helpers', () => {
|
||||
let mockFetch: ReturnType<typeof vi.fn>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user