feat: add likes command
Add `bird likes` command to fetch the authenticated user's liked tweets, similar to the existing bookmarks command. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
f264e36cbe
commit
676bff71f3
@@ -150,6 +150,7 @@ const KNOWN_COMMANDS = new Set([
|
|||||||
'search',
|
'search',
|
||||||
'mentions',
|
'mentions',
|
||||||
'bookmarks',
|
'bookmarks',
|
||||||
|
'likes',
|
||||||
'help',
|
'help',
|
||||||
'whoami',
|
'whoami',
|
||||||
'check',
|
'check',
|
||||||
@@ -347,6 +348,7 @@ program
|
|||||||
'SearchTimeline',
|
'SearchTimeline',
|
||||||
'UserArticlesTweets',
|
'UserArticlesTweets',
|
||||||
'Bookmarks',
|
'Bookmarks',
|
||||||
|
'Likes',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (cmdOpts.fresh) {
|
if (cmdOpts.fresh) {
|
||||||
@@ -741,6 +743,39 @@ program
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Likes command - get user's liked tweets
|
||||||
|
program
|
||||||
|
.command('likes')
|
||||||
|
.description('Get your liked tweets')
|
||||||
|
.option('-n, --count <number>', 'Number of likes to fetch', '20')
|
||||||
|
.option('--json', 'Output as JSON')
|
||||||
|
.action(async (cmdOpts: { count?: string; json?: boolean }) => {
|
||||||
|
const opts = program.opts();
|
||||||
|
const timeoutMs = resolveTimeoutFromOptions(opts);
|
||||||
|
const count = Number.parseInt(cmdOpts.count || '20', 10);
|
||||||
|
|
||||||
|
const { cookies, warnings } = await resolveCredentialsFromOptions(opts);
|
||||||
|
|
||||||
|
for (const warning of warnings) {
|
||||||
|
console.error(`${p('warn')}${warning}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cookies.authToken || !cookies.ct0) {
|
||||||
|
console.error(`${p('err')}Missing required credentials`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies, timeoutMs });
|
||||||
|
const result = await client.getLikes(count);
|
||||||
|
|
||||||
|
if (result.success && result.tweets) {
|
||||||
|
printTweets(result.tweets, { json: cmdOpts.json, emptyMessage: 'No liked tweets found.' });
|
||||||
|
} else {
|
||||||
|
console.error(`${p('err')}Failed to fetch likes: ${result.error}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Whoami command - show the logged-in account
|
// Whoami command - show the logged-in account
|
||||||
program
|
program
|
||||||
.command('whoami')
|
.command('whoami')
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const FALLBACK_QUERY_IDS = {
|
|||||||
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
|
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
|
||||||
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
|
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
|
||||||
Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw',
|
Bookmarks: 'RV1g3b8n_SGOHwkqKYSCFw',
|
||||||
|
Likes: 'JR2gceKucIKcVNB_9JkhsA',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type OperationName = keyof typeof FALLBACK_QUERY_IDS;
|
type OperationName = keyof typeof FALLBACK_QUERY_IDS;
|
||||||
@@ -1987,4 +1988,117 @@ export class TwitterClient {
|
|||||||
|
|
||||||
return { success: false, error: firstAttempt.error };
|
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']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the authenticated user's liked tweets
|
||||||
|
*/
|
||||||
|
async getLikes(count = 20): Promise<SearchResult> {
|
||||||
|
const userResult = await this.getCurrentUser();
|
||||||
|
if (!userResult.success || !userResult.user) {
|
||||||
|
return { success: false, error: userResult.error ?? 'Could not determine current user' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const variables = {
|
||||||
|
userId: userResult.user.id,
|
||||||
|
count,
|
||||||
|
includePromotedContent: false,
|
||||||
|
withClientEventToken: false,
|
||||||
|
withBirdwatchNotes: false,
|
||||||
|
withVoice: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const features = this.buildBookmarksFeatures();
|
||||||
|
|
||||||
|
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.getLikesQueryIds();
|
||||||
|
|
||||||
|
for (const queryId of queryIds) {
|
||||||
|
const url = `${TWITTER_API_BASE}/${queryId}/Likes?${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?: {
|
||||||
|
user?: {
|
||||||
|
result?: {
|
||||||
|
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?.user?.result?.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 likes', 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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user