diff --git a/src/api-data.ts b/src/api-data.ts index d97d363d..1311972f 100644 --- a/src/api-data.ts +++ b/src/api-data.ts @@ -9,6 +9,8 @@ const endpoints = { // TODO: Migrate other endpoint URLs here UserTweets: 'https://twitter.com/i/api/graphql/H8OOoI-5ZE4NxgRr8lfyWg/UserTweets?variables=%7B%22userId%22%3A%2244196397%22%2C%22count%22%3A20%2C%22includePromotedContent%22%3Atrue%2C%22withQuickPromoteEligibilityTweetFields%22%3Atrue%2C%22withVoice%22%3Atrue%2C%22withV2Timeline%22%3Atrue%7D&features=%7B%22responsive_web_graphql_exclude_directive_enabled%22%3Atrue%2C%22verified_phone_label_enabled%22%3Afalse%2C%22creator_subscriptions_tweet_preview_api_enabled%22%3Atrue%2C%22responsive_web_graphql_timeline_navigation_enabled%22%3Atrue%2C%22responsive_web_graphql_skip_user_profile_image_extensions_enabled%22%3Afalse%2C%22tweetypie_unmention_optimization_enabled%22%3Atrue%2C%22responsive_web_edit_tweet_api_enabled%22%3Atrue%2C%22graphql_is_translatable_rweb_tweet_is_translatable_enabled%22%3Atrue%2C%22view_counts_everywhere_api_enabled%22%3Atrue%2C%22longform_notetweets_consumption_enabled%22%3Atrue%2C%22responsive_web_twitter_article_tweet_consumption_enabled%22%3Afalse%2C%22tweet_awards_web_tipping_enabled%22%3Afalse%2C%22freedom_of_speech_not_reach_fetch_enabled%22%3Atrue%2C%22standardized_nudges_misinfo%22%3Atrue%2C%22tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled%22%3Atrue%2C%22longform_notetweets_rich_text_read_enabled%22%3Atrue%2C%22longform_notetweets_inline_media_enabled%22%3Atrue%2C%22responsive_web_media_download_video_enabled%22%3Afalse%2C%22responsive_web_enhance_cards_enabled%22%3Afalse%7D', + UserLikedTweets: + 'https://twitter.com/i/api/graphql/eSSNbhECHHWWALkkQq-YTA/Likes?variables=%7B%22userId%22%3A%222244196397%22%2C%22count%22%3A20%2C%22includePromotedContent%22%3Afalse%2C%22withClientEventToken%22%3Afalse%2C%22withBirdwatchNotes%22%3Afalse%2C%22withVoice%22%3Atrue%2C%22withV2Timeline%22%3Atrue%7D&features=%7B%22responsive_web_graphql_exclude_directive_enabled%22%3Atrue%2C%22verified_phone_label_enabled%22%3Afalse%2C%22creator_subscriptions_tweet_preview_api_enabled%22%3Atrue%2C%22responsive_web_graphql_timeline_navigation_enabled%22%3Atrue%2C%22responsive_web_graphql_skip_user_profile_image_extensions_enabled%22%3Afalse%2C%22c9s_tweet_anatomy_moderator_badge_enabled%22%3Atrue%2C%22tweetypie_unmention_optimization_enabled%22%3Atrue%2C%22responsive_web_edit_tweet_api_enabled%22%3Atrue%2C%22graphql_is_translatable_rweb_tweet_is_translatable_enabled%22%3Atrue%2C%22view_counts_everywhere_api_enabled%22%3Atrue%2C%22longform_notetweets_consumption_enabled%22%3Atrue%2C%22responsive_web_twitter_article_tweet_consumption_enabled%22%3Atrue%2C%22tweet_awards_web_tipping_enabled%22%3Afalse%2C%22freedom_of_speech_not_reach_fetch_enabled%22%3Atrue%2C%22standardized_nudges_misinfo%22%3Atrue%2C%22tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled%22%3Atrue%2C%22rweb_video_timestamps_enabled%22%3Atrue%2C%22longform_notetweets_rich_text_read_enabled%22%3Atrue%2C%22longform_notetweets_inline_media_enabled%22%3Atrue%2C%22responsive_web_enhance_cards_enabled%22%3Afalse%7D', TweetDetail: 'https://twitter.com/i/api/graphql/xOhkmRac04YFZmOzU9PJHg/TweetDetail?variables=%7B%22focalTweetId%22%3A%221237110546383724547%22%2C%22with_rux_injections%22%3Afalse%2C%22includePromotedContent%22%3Atrue%2C%22withCommunity%22%3Atrue%2C%22withQuickPromoteEligibilityTweetFields%22%3Atrue%2C%22withBirdwatchNotes%22%3Atrue%2C%22withVoice%22%3Atrue%2C%22withV2Timeline%22%3Atrue%7D&features=%7B%22responsive_web_graphql_exclude_directive_enabled%22%3Atrue%2C%22verified_phone_label_enabled%22%3Afalse%2C%22creator_subscriptions_tweet_preview_api_enabled%22%3Atrue%2C%22responsive_web_graphql_timeline_navigation_enabled%22%3Atrue%2C%22responsive_web_graphql_skip_user_profile_image_extensions_enabled%22%3Afalse%2C%22tweetypie_unmention_optimization_enabled%22%3Atrue%2C%22responsive_web_edit_tweet_api_enabled%22%3Atrue%2C%22graphql_is_translatable_rweb_tweet_is_translatable_enabled%22%3Atrue%2C%22view_counts_everywhere_api_enabled%22%3Atrue%2C%22longform_notetweets_consumption_enabled%22%3Atrue%2C%22responsive_web_twitter_article_tweet_consumption_enabled%22%3Afalse%2C%22tweet_awards_web_tipping_enabled%22%3Afalse%2C%22freedom_of_speech_not_reach_fetch_enabled%22%3Atrue%2C%22standardized_nudges_misinfo%22%3Atrue%2C%22tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled%22%3Atrue%2C%22longform_notetweets_rich_text_read_enabled%22%3Atrue%2C%22longform_notetweets_inline_media_enabled%22%3Atrue%2C%22responsive_web_media_download_video_enabled%22%3Afalse%2C%22responsive_web_enhance_cards_enabled%22%3Afalse%7D&fieldToggles=%7B%22withArticleRichContentState%22%3Afalse%7D', TweetResultByRestId: diff --git a/src/scraper.ts b/src/scraper.ts index c6453bcc..653ebbc9 100644 --- a/src/scraper.ts +++ b/src/scraper.ts @@ -23,6 +23,7 @@ import { getTweetAnonymous, getTweets, getLatestTweet, + getLikedTweets, getTweetWhere, getTweetsWhere, getTweetsByUserId, @@ -248,6 +249,16 @@ export class Scraper { return getTweets(user, maxTweets, this.auth); } + /** + * Fetches liked tweets from a Twitter user. Requires authentication. + * @param user The user whose likes should be returned. + * @param maxTweets The maximum number of tweets to return. Defaults to `200`. + * @returns An {@link AsyncGenerator} of liked tweets from the provided user. + */ + public getLikedTweets(user: string, maxTweets = 200): AsyncGenerator { + return getLikedTweets(user, maxTweets, this.auth); + } + /** * Fetches tweets from a Twitter user using their ID. * @param userId The user whose tweets should be returned. diff --git a/src/tweets.test.ts b/src/tweets.test.ts index d1b36a77..6bc0afea 100644 --- a/src/tweets.test.ts +++ b/src/tweets.test.ts @@ -299,3 +299,12 @@ test('scraper can get tweet thread', async () => { expect(tweet?.isSelfThread).toBeTruthy(); expect(tweet?.thread.length).toStrictEqual(7); }); + +test('scraper can get liked tweets', async () => { + const scraper = await getScraper(); + const liked = scraper.getLikedTweets('elonmusk', 10); + const tweet = await liked.next(); + expect(tweet.value).not.toBeUndefined(); + expect(tweet.done).toBeFalsy(); + expect(tweet.value?.id).not.toBeUndefined(); +}); diff --git a/src/tweets.ts b/src/tweets.ts index 9e0c2490..c53d04b6 100644 --- a/src/tweets.ts +++ b/src/tweets.ts @@ -186,6 +186,59 @@ export function getTweetsByUserId( }); } +export async function fetchLikedTweets( + userId: string, + maxTweets: number, + cursor: string | undefined, + auth: TwitterAuth, +): Promise { + if (!auth.isLoggedIn()) { + throw new Error('Scraper is not logged-in for fetching liked tweets.'); + } + + if (maxTweets > 200) { + maxTweets = 200; + } + + const userTweetsRequest = apiRequestFactory.createUserLikedTweetsRequest(); + userTweetsRequest.variables.userId = userId; + userTweetsRequest.variables.count = maxTweets; + userTweetsRequest.variables.includePromotedContent = false; // true on the website + + if (cursor != null && cursor != '') { + userTweetsRequest.variables['cursor'] = cursor; + } + + const res = await requestApi( + userTweetsRequest.toRequestUrl(), + auth, + ); + + if (!res.success) { + throw res.err; + } + + return parseTimelineTweetsV2(res.value); +} + +export function getLikedTweets( + user: string, + maxTweets: number, + auth: TwitterAuth, +): AsyncGenerator { + return getTweetTimeline(user, maxTweets, async (q, mt, c) => { + const userIdRes = await getUserIdByScreenName(q, auth); + + if (!userIdRes.success) { + throw userIdRes.err; + } + + const { value: userId } = userIdRes; + + return fetchLikedTweets(userId, mt, c, auth); + }); +} + export async function getTweetWhere( tweets: AsyncIterable, query: TweetQuery,