agent-twitter-summary
Version:
A twitter client for agents
1,482 lines (1,312 loc) • 43 kB
text/typescript
import { addApiFeatures, requestApi } from './api';
import { TwitterAuth } from './auth';
import { getUserIdByScreenName } from './profile';
import { QueryTweetsResponse } from './timeline-v1';
import {
parseTimelineTweetsV2,
TimelineV2,
TimelineEntryItemContentRaw,
parseTimelineEntryItemContentRaw,
ThreadedConversation,
parseThreadedConversation,
} from './timeline-v2';
import { getTweetTimeline } from './timeline-async';
import { apiRequestFactory } from './api-data';
import { ListTimeline, parseListTimelineTweets } from './timeline-list';
import { updateCookieJar } from './requests';
import {
ApiV2Includes,
MediaObjectV2,
PlaceV2,
PollV2,
TTweetv2Expansion,
TTweetv2MediaField,
TTweetv2PlaceField,
TTweetv2PollField,
TTweetv2TweetField,
TTweetv2UserField,
TweetV2,
UserV2,
} from 'twitter-api-v2';
export const defaultOptions = {
expansions: [
'attachments.poll_ids',
'attachments.media_keys',
'author_id',
'referenced_tweets.id',
'in_reply_to_user_id',
'edit_history_tweet_ids',
'geo.place_id',
'entities.mentions.username',
'referenced_tweets.id.author_id',
] as TTweetv2Expansion[],
tweetFields: [
'attachments',
'author_id',
'context_annotations',
'conversation_id',
'created_at',
'entities',
'geo',
'id',
'in_reply_to_user_id',
'lang',
'public_metrics',
'edit_controls',
'possibly_sensitive',
'referenced_tweets',
'reply_settings',
'source',
'text',
'withheld',
'note_tweet',
] as TTweetv2TweetField[],
pollFields: [
'duration_minutes',
'end_datetime',
'id',
'options',
'voting_status',
] as TTweetv2PollField[],
mediaFields: [
'duration_ms',
'height',
'media_key',
'preview_image_url',
'type',
'url',
'width',
'public_metrics',
'alt_text',
'variants',
] as TTweetv2MediaField[],
userFields: [
'created_at',
'description',
'entities',
'id',
'location',
'name',
'profile_image_url',
'protected',
'public_metrics',
'url',
'username',
'verified',
'withheld',
] as TTweetv2UserField[],
placeFields: [
'contained_within',
'country',
'country_code',
'full_name',
'geo',
'id',
'name',
'place_type',
] as TTweetv2PlaceField[],
};
export interface Mention {
id: string;
username?: string;
name?: string;
}
export interface Photo {
id: string;
url: string;
alt_text: string | undefined;
}
export interface Video {
id: string;
preview: string;
url?: string;
}
export interface PlaceRaw {
id?: string;
place_type?: string;
name?: string;
full_name?: string;
country_code?: string;
country?: string;
bounding_box?: {
type?: string;
coordinates?: number[][][];
};
}
export interface PollData {
id?: string;
end_datetime?: string;
voting_status?: string;
duration_minutes: number;
options: PollOption[];
}
export interface PollOption {
position?: number;
label: string;
votes?: number;
}
/**
* A parsed Tweet object.
*/
export interface Tweet {
bookmarkCount?: number;
conversationId?: string;
hashtags: string[];
html?: string;
id?: string;
inReplyToStatus?: Tweet;
inReplyToStatusId?: string;
isQuoted?: boolean;
isPin?: boolean;
isReply?: boolean;
isRetweet?: boolean;
isSelfThread?: boolean;
likes?: number;
name?: string;
mentions: Mention[];
permanentUrl?: string;
photos: Photo[];
place?: PlaceRaw;
quotedStatus?: Tweet;
quotedStatusId?: string;
replies?: number;
retweets?: number;
retweetedStatus?: Tweet;
retweetedStatusId?: string;
text?: string;
thread: Tweet[];
timeParsed?: Date;
timestamp?: number;
urls: string[];
userId?: string;
username?: string;
videos: Video[];
views?: number;
sensitiveContent?: boolean;
poll?: PollV2 | null;
}
export type TweetQuery =
| Partial<Tweet>
| ((tweet: Tweet) => boolean | Promise<boolean>);
export const features = addApiFeatures({
interactive_text_enabled: true,
longform_notetweets_inline_media_enabled: false,
responsive_web_text_conversations_enabled: false,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled:
false,
vibe_api_enabled: false,
});
export async function fetchTweets(
userId: string,
maxTweets: number,
cursor: string | undefined,
auth: TwitterAuth,
): Promise<QueryTweetsResponse> {
if (maxTweets > 200) {
maxTweets = 200;
}
const userTweetsRequest = apiRequestFactory.createUserTweetsRequest();
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<TimelineV2>(
userTweetsRequest.toRequestUrl(),
auth,
);
if (!res.success) {
throw res.err;
}
return parseTimelineTweetsV2(res.value);
}
export async function fetchTweetsAndReplies(
userId: string,
maxTweets: number,
cursor: string | undefined,
auth: TwitterAuth,
): Promise<QueryTweetsResponse> {
if (maxTweets > 40) {
maxTweets = 40;
}
const userTweetsRequest =
apiRequestFactory.createUserTweetsAndRepliesRequest();
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<TimelineV2>(
userTweetsRequest.toRequestUrl(),
auth,
);
if (!res.success) {
throw res.err;
}
return parseTimelineTweetsV2(res.value);
}
export async function createCreateTweetRequestV2(
text: string,
auth: TwitterAuth,
tweetId?: string,
options?: {
poll?: PollData;
},
) {
const v2client = auth.getV2Client();
if (v2client == null) {
throw new Error('V2 client is not initialized');
}
const { poll } = options || {};
let tweetConfig;
if (poll) {
tweetConfig = {
text,
poll: {
options: poll?.options.map((option) => option.label) ?? [],
duration_minutes: poll?.duration_minutes ?? 60,
},
};
} else if (tweetId) {
tweetConfig = {
text,
reply: {
in_reply_to_tweet_id: tweetId,
},
};
} else {
tweetConfig = {
text,
};
}
const tweetResponse = await v2client.v2.tweet(tweetConfig);
let optionsConfig = {};
if (options?.poll) {
optionsConfig = {
expansions: ['attachments.poll_ids'],
pollFields: [
'options',
'duration_minutes',
'end_datetime',
'voting_status',
],
};
}
return await getTweetV2(tweetResponse.data.id, auth, optionsConfig);
}
export function parseTweetV2ToV1(
tweetV2: TweetV2,
includes?: ApiV2Includes,
defaultTweetData?: Tweet | null,
): Tweet {
let parsedTweet: Tweet;
if (defaultTweetData != null) {
parsedTweet = defaultTweetData;
}
parsedTweet = {
id: tweetV2.id,
text: tweetV2.text ?? defaultTweetData?.text ?? '',
hashtags:
tweetV2.entities?.hashtags?.map((tag) => tag.tag) ??
defaultTweetData?.hashtags ??
[],
mentions:
tweetV2.entities?.mentions?.map((mention) => ({
id: mention.id,
username: mention.username,
})) ??
defaultTweetData?.mentions ??
[],
urls:
tweetV2.entities?.urls?.map((url) => url.url) ??
defaultTweetData?.urls ??
[],
likes: tweetV2.public_metrics?.like_count ?? defaultTweetData?.likes ?? 0,
retweets:
tweetV2.public_metrics?.retweet_count ?? defaultTweetData?.retweets ?? 0,
replies:
tweetV2.public_metrics?.reply_count ?? defaultTweetData?.replies ?? 0,
views:
tweetV2.public_metrics?.impression_count ?? defaultTweetData?.views ?? 0,
userId: tweetV2.author_id ?? defaultTweetData?.userId,
conversationId: tweetV2.conversation_id ?? defaultTweetData?.conversationId,
photos: defaultTweetData?.photos ?? [],
videos: defaultTweetData?.videos ?? [],
poll: defaultTweetData?.poll ?? null,
username: defaultTweetData?.username ?? '',
name: defaultTweetData?.name ?? '',
place: defaultTweetData?.place,
thread: defaultTweetData?.thread ?? [],
};
// Process Polls
if (includes?.polls?.length) {
const poll = includes.polls[0];
parsedTweet.poll = {
id: poll.id,
end_datetime: poll.end_datetime
? poll.end_datetime
: defaultTweetData?.poll?.end_datetime
? defaultTweetData?.poll?.end_datetime
: undefined,
options: poll.options.map((option) => ({
position: option.position,
label: option.label,
votes: option.votes,
})),
voting_status:
poll.voting_status ?? defaultTweetData?.poll?.voting_status,
};
}
// Process Media (photos and videos)
if (includes?.media?.length) {
includes.media.forEach((media: MediaObjectV2) => {
if (media.type === 'photo') {
parsedTweet.photos.push({
id: media.media_key,
url: media.url ?? '',
alt_text: media.alt_text ?? '',
});
} else if (media.type === 'video' || media.type === 'animated_gif') {
parsedTweet.videos.push({
id: media.media_key,
preview: media.preview_image_url ?? '',
url:
media.variants?.find(
(variant) => variant.content_type === 'video/mp4',
)?.url ?? '',
});
}
});
}
// Process User (for author info)
if (includes?.users?.length) {
const user = includes.users.find(
(user: UserV2) => user.id === tweetV2.author_id,
);
if (user) {
parsedTweet.username = user.username ?? defaultTweetData?.username ?? '';
parsedTweet.name = user.name ?? defaultTweetData?.name ?? '';
}
}
// Process Place (if any)
if (tweetV2?.geo?.place_id && includes?.places?.length) {
const place = includes.places.find(
(place: PlaceV2) => place.id === tweetV2?.geo?.place_id,
);
if (place) {
parsedTweet.place = {
id: place.id,
full_name: place.full_name ?? defaultTweetData?.place?.full_name ?? '',
country: place.country ?? defaultTweetData?.place?.country ?? '',
country_code:
place.country_code ?? defaultTweetData?.place?.country_code ?? '',
name: place.name ?? defaultTweetData?.place?.name ?? '',
place_type: place.place_type ?? defaultTweetData?.place?.place_type,
};
}
}
// TODO: Process Thread (referenced tweets) and remove reference to v1
return parsedTweet;
}
export async function createCreateTweetRequest(
text: string,
auth: TwitterAuth,
tweetId?: string,
mediaData?: { data: Buffer; mediaType: string }[],
) {
const onboardingTaskUrl = 'https://api.twitter.com/1.1/onboarding/task.json';
const cookies = await auth.cookieJar().getCookies(onboardingTaskUrl);
const xCsrfToken = cookies.find((cookie) => cookie.key === 'ct0');
//@ ts-expect-error - This is a private API.
const headers = new Headers({
authorization: `Bearer ${(auth as any).bearerToken}`,
cookie: await auth.cookieJar().getCookieString(onboardingTaskUrl),
'content-type': 'application/json',
'User-Agent':
'Mozilla/5.0 (Linux; Android 11; Nokia G20) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.88 Mobile Safari/537.36',
'x-guest-token': (auth as any).guestToken,
'x-twitter-auth-type': 'OAuth2Client',
'x-twitter-active-user': 'yes',
'x-twitter-client-language': 'en',
'x-csrf-token': xCsrfToken?.value as string,
});
const variables: Record<string, any> = {
tweet_text: text,
dark_request: false,
media: {
media_entities: [],
possibly_sensitive: false,
},
semantic_annotation_ids: [],
};
if (mediaData && mediaData.length > 0) {
const mediaIds = await Promise.all(
mediaData.map(({ data, mediaType }) =>
uploadMedia(data, auth, mediaType),
),
);
variables.media.media_entities = mediaIds.map((id) => ({
media_id: id,
tagged_users: [],
}));
}
if (tweetId) {
variables.reply = { in_reply_to_tweet_id: tweetId };
}
const response = await fetch(
'https://twitter.com/i/api/graphql/a1p9RWpkYKBjWv_I3WzS-A/CreateTweet',
{
headers,
body: JSON.stringify({
variables,
features: {
interactive_text_enabled: true,
longform_notetweets_inline_media_enabled: false,
responsive_web_text_conversations_enabled: false,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled:
false,
vibe_api_enabled: false,
rweb_lists_timeline_redesign_enabled: true,
responsive_web_graphql_exclude_directive_enabled: true,
verified_phone_label_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled:
false,
tweetypie_unmention_optimization_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
tweet_awards_web_tipping_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
longform_notetweets_rich_text_read_enabled: true,
responsive_web_enhance_cards_enabled: false,
subscriptions_verification_info_enabled: true,
subscriptions_verification_info_reason_enabled: true,
subscriptions_verification_info_verified_since_enabled: true,
super_follow_badge_privacy_enabled: false,
super_follow_exclusive_tweet_notifications_enabled: false,
super_follow_tweet_api_enabled: false,
super_follow_user_api_enabled: false,
android_graphql_skip_api_media_color_palette: false,
creator_subscriptions_subscription_count_enabled: false,
blue_business_profile_image_shape_enabled: false,
unified_cards_ad_metadata_container_dynamic_card_content_query_enabled:
false,
rweb_video_timestamps_enabled: false,
c9s_tweet_anatomy_moderator_badge_enabled: false,
responsive_web_twitter_article_tweet_consumption_enabled: false,
},
fieldToggles: {},
}),
method: 'POST',
},
);
await updateCookieJar(auth.cookieJar(), response.headers);
// check for errors
if (!response.ok) {
throw new Error(await response.text());
}
return response;
}
export async function createCreateNoteTweetRequest(
text: string,
auth: TwitterAuth,
tweetId?: string,
mediaData?: { data: Buffer; mediaType: string }[],
) {
const onboardingTaskUrl = 'https://api.twitter.com/1.1/onboarding/task.json';
const cookies = await auth.cookieJar().getCookies(onboardingTaskUrl);
const xCsrfToken = cookies.find((cookie) => cookie.key === 'ct0');
const headers = new Headers({
authorization: `Bearer ${(auth as any).bearerToken}`,
cookie: await auth.cookieJar().getCookieString(onboardingTaskUrl),
'content-type': 'application/json',
'User-Agent':
'Mozilla/5.0 (Linux; Android 11; Nokia G20) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.88 Mobile Safari/537.36',
'x-guest-token': (auth as any).guestToken,
'x-twitter-auth-type': 'OAuth2Client',
'x-twitter-active-user': 'yes',
'x-twitter-client-language': 'en',
'x-csrf-token': xCsrfToken?.value as string,
});
const variables: Record<string, any> = {
tweet_text: text,
dark_request: false,
media: {
media_entities: [],
possibly_sensitive: false,
},
semantic_annotation_ids: [],
};
if (mediaData && mediaData.length > 0) {
const mediaIds = await Promise.all(
mediaData.map(({ data, mediaType }) =>
uploadMedia(data, auth, mediaType),
),
);
variables.media.media_entities = mediaIds.map((id) => ({
media_id: id,
tagged_users: [],
}));
}
if (tweetId) {
variables.reply = { in_reply_to_tweet_id: tweetId };
}
const response = await fetch(
'https://twitter.com/i/api/graphql/0aWhJJmFlxkxv9TAUJPanA/CreateNoteTweet',
{
headers,
body: JSON.stringify({
variables,
features: {
interactive_text_enabled: true,
longform_notetweets_inline_media_enabled: false,
responsive_web_text_conversations_enabled: false,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled:
false,
vibe_api_enabled: false,
rweb_lists_timeline_redesign_enabled: true,
responsive_web_graphql_exclude_directive_enabled: true,
verified_phone_label_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled:
false,
tweetypie_unmention_optimization_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
longform_notetweets_creation_enabled: true,
tweet_awards_web_tipping_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
longform_notetweets_rich_text_read_enabled: true,
responsive_web_enhance_cards_enabled: false,
subscriptions_verification_info_enabled: true,
subscriptions_verification_info_reason_enabled: true,
subscriptions_verification_info_verified_since_enabled: true,
super_follow_badge_privacy_enabled: false,
super_follow_exclusive_tweet_notifications_enabled: false,
super_follow_tweet_api_enabled: false,
super_follow_user_api_enabled: false,
android_graphql_skip_api_media_color_palette: false,
creator_subscriptions_subscription_count_enabled: false,
blue_business_profile_image_shape_enabled: false,
unified_cards_ad_metadata_container_dynamic_card_content_query_enabled:
false,
rweb_video_timestamps_enabled: false,
c9s_tweet_anatomy_moderator_badge_enabled: false,
responsive_web_twitter_article_tweet_consumption_enabled: false,
communities_web_enable_tweet_community_results_fetch: false,
articles_preview_enabled: false,
rweb_tipjar_consumption_enabled: false,
creator_subscriptions_quote_tweet_preview_enabled: false,
},
fieldToggles: {},
}),
method: 'POST',
},
);
await updateCookieJar(auth.cookieJar(), response.headers);
// Check for errors and log the error response
if (!response.ok) {
const errorText = await response.text();
console.error('Error response:', errorText);
throw new Error(`Failed to create long tweet: ${errorText}`);
}
// Parse and return the response
const data = await response.json();
return data;
}
export async function fetchListTweets(
listId: string,
maxTweets: number,
cursor: string | undefined,
auth: TwitterAuth,
): Promise<QueryTweetsResponse> {
if (maxTweets > 200) {
maxTweets = 200;
}
const listTweetsRequest = apiRequestFactory.createListTweetsRequest();
listTweetsRequest.variables.listId = listId;
listTweetsRequest.variables.count = maxTweets;
if (cursor != null && cursor != '') {
listTweetsRequest.variables['cursor'] = cursor;
}
const res = await requestApi<ListTimeline>(
listTweetsRequest.toRequestUrl(),
auth,
);
if (!res.success) {
throw res.err;
}
return parseListTimelineTweets(res.value);
}
export function getTweets(
user: string,
maxTweets: number,
auth: TwitterAuth,
): AsyncGenerator<Tweet, void> {
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 fetchTweets(userId, mt, c, auth);
});
}
export function getTweetsByUserId(
userId: string,
maxTweets: number,
auth: TwitterAuth,
): AsyncGenerator<Tweet, void> {
return getTweetTimeline(userId, maxTweets, (q, mt, c) => {
return fetchTweets(q, mt, c, auth);
});
}
export function getTweetsAndReplies(
user: string,
maxTweets: number,
auth: TwitterAuth,
): AsyncGenerator<Tweet, void> {
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 fetchTweetsAndReplies(userId, mt, c, auth);
});
}
export function getTweetsAndRepliesByUserId(
userId: string,
maxTweets: number,
auth: TwitterAuth,
): AsyncGenerator<Tweet, void> {
return getTweetTimeline(userId, maxTweets, (q, mt, c) => {
return fetchTweetsAndReplies(q, mt, c, auth);
});
}
export async function fetchLikedTweets(
userId: string,
maxTweets: number,
cursor: string | undefined,
auth: TwitterAuth,
): Promise<QueryTweetsResponse> {
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<TimelineV2>(
userTweetsRequest.toRequestUrl(),
auth,
);
if (!res.success) {
throw res.err;
}
return parseTimelineTweetsV2(res.value);
}
export async function getTweetWhere(
tweets: AsyncIterable<Tweet>,
query: TweetQuery,
): Promise<Tweet | null> {
const isCallback = typeof query === 'function';
for await (const tweet of tweets) {
const matches = isCallback
? await query(tweet)
: checkTweetMatches(tweet, query);
if (matches) {
return tweet;
}
}
return null;
}
export async function getTweetsWhere(
tweets: AsyncIterable<Tweet>,
query: TweetQuery,
): Promise<Tweet[]> {
const isCallback = typeof query === 'function';
const filtered = [];
for await (const tweet of tweets) {
const matches = isCallback ? query(tweet) : checkTweetMatches(tweet, query);
if (!matches) continue;
filtered.push(tweet);
}
return filtered;
}
function checkTweetMatches(tweet: Tweet, options: Partial<Tweet>): boolean {
return Object.keys(options).every((k) => {
const key = k as keyof Tweet;
return tweet[key] === options[key];
});
}
export async function getLatestTweet(
user: string,
includeRetweets: boolean,
max: number,
auth: TwitterAuth,
): Promise<Tweet | null | void> {
const timeline = getTweets(user, max, auth);
// No point looping if max is 1, just use first entry.
return max === 1
? (await timeline.next()).value
: await getTweetWhere(timeline, { isRetweet: includeRetweets });
}
export interface TweetResultByRestId {
data?: TimelineEntryItemContentRaw;
}
export async function getTweet(
id: string,
auth: TwitterAuth,
): Promise<Tweet | null> {
const tweetDetailRequest = apiRequestFactory.createTweetDetailRequest();
tweetDetailRequest.variables.focalTweetId = id;
const res = await requestApi<ThreadedConversation>(
tweetDetailRequest.toRequestUrl(),
auth,
);
if (!res.success) {
throw res.err;
}
if (!res.value) {
return null;
}
const tweets = parseThreadedConversation(res.value);
return tweets.find((tweet) => tweet.id === id) ?? null;
}
export async function getTweetV2(
id: string,
auth: TwitterAuth,
options: {
expansions?: TTweetv2Expansion[];
tweetFields?: TTweetv2TweetField[];
pollFields?: TTweetv2PollField[];
mediaFields?: TTweetv2MediaField[];
userFields?: TTweetv2UserField[];
placeFields?: TTweetv2PlaceField[];
} = defaultOptions,
): Promise<Tweet | null> {
const v2client = auth.getV2Client();
if (!v2client) {
throw new Error('V2 client is not initialized');
}
try {
const tweetData = await v2client.v2.singleTweet(id, {
expansions: options?.expansions,
'tweet.fields': options?.tweetFields,
'poll.fields': options?.pollFields,
'media.fields': options?.mediaFields,
'user.fields': options?.userFields,
'place.fields': options?.placeFields,
});
if (!tweetData?.data) {
console.warn(`Tweet data not found for ID: ${id}`);
return null;
}
const defaultTweetData = await getTweet(tweetData.data.id, auth);
// Extract primary tweet data
const parsedTweet = parseTweetV2ToV1(
tweetData.data,
tweetData?.includes,
defaultTweetData,
);
return parsedTweet;
} catch (error) {
console.error(`Error fetching tweet ${id}:`, error);
return null;
}
}
export async function getTweetsV2(
ids: string[],
auth: TwitterAuth,
options: {
expansions?: TTweetv2Expansion[];
tweetFields?: TTweetv2TweetField[];
pollFields?: TTweetv2PollField[];
mediaFields?: TTweetv2MediaField[];
userFields?: TTweetv2UserField[];
placeFields?: TTweetv2PlaceField[];
} = defaultOptions,
): Promise<Tweet[]> {
const v2client = auth.getV2Client();
if (!v2client) {
return [];
}
try {
const tweetData = await v2client.v2.tweets(ids, {
expansions: options?.expansions,
'tweet.fields': options?.tweetFields,
'poll.fields': options?.pollFields,
'media.fields': options?.mediaFields,
'user.fields': options?.userFields,
'place.fields': options?.placeFields,
});
const tweetsV2 = tweetData.data;
if (tweetsV2.length === 0) {
console.warn(`No tweet data found for IDs: ${ids.join(', ')}`);
return [];
}
return (
await Promise.all(
tweetsV2.map(
async (tweet) => await getTweetV2(tweet.id, auth, options),
),
)
).filter((tweet): tweet is Tweet => tweet !== null);
} catch (error) {
console.error(`Error fetching tweets for IDs: ${ids.join(', ')}`, error);
return [];
}
}
export async function getTweetAnonymous(
id: string,
auth: TwitterAuth,
): Promise<Tweet | null> {
const tweetResultByRestIdRequest =
apiRequestFactory.createTweetResultByRestIdRequest();
tweetResultByRestIdRequest.variables.tweetId = id;
const res = await requestApi<TweetResultByRestId>(
tweetResultByRestIdRequest.toRequestUrl(),
auth,
);
if (!res.success) {
throw res.err;
}
if (!res.value.data) {
return null;
}
return parseTimelineEntryItemContentRaw(res.value.data, id);
}
interface MediaUploadResponse {
media_id_string: string;
size: number;
expires_after_secs: number;
image: {
image_type: string;
w: number;
h: number;
};
}
async function uploadMedia(
mediaData: Buffer,
auth: TwitterAuth,
mediaType: string,
): Promise<string> {
const uploadUrl = 'https://upload.twitter.com/1.1/media/upload.json';
// Get authentication headers
const cookies = await auth.cookieJar().getCookies(uploadUrl);
const xCsrfToken = cookies.find((cookie) => cookie.key === 'ct0');
const headers = new Headers({
authorization: `Bearer ${(auth as any).bearerToken}`,
cookie: await auth.cookieJar().getCookieString(uploadUrl),
'x-csrf-token': xCsrfToken?.value as string,
});
// Detect if media is a video based on mediaType
const isVideo = mediaType.startsWith('video/');
if (isVideo) {
// Handle video upload using chunked media upload
const mediaId = await uploadVideoInChunks(mediaData, mediaType);
return mediaId;
} else {
// Handle image upload
const form = new FormData();
form.append('media', new Blob([mediaData]));
const response = await fetch(uploadUrl, {
method: 'POST',
headers,
body: form,
});
await updateCookieJar(auth.cookieJar(), response.headers);
if (!response.ok) {
throw new Error(await response.text());
}
const data: MediaUploadResponse = await response.json();
return data.media_id_string;
}
// Function to upload video in chunks
async function uploadVideoInChunks(
mediaData: Buffer,
mediaType: string,
): Promise<string> {
// Initialize upload
const initParams = new URLSearchParams();
initParams.append('command', 'INIT');
initParams.append('media_type', mediaType);
initParams.append('total_bytes', mediaData.length.toString());
const initResponse = await fetch(uploadUrl, {
method: 'POST',
headers,
body: initParams,
});
if (!initResponse.ok) {
throw new Error(await initResponse.text());
}
const initData = await initResponse.json();
const mediaId = initData.media_id_string;
// Append upload in chunks
const segmentSize = 5 * 1024 * 1024; // 5 MB per chunk
let segmentIndex = 0;
for (let offset = 0; offset < mediaData.length; offset += segmentSize) {
const chunk = mediaData.slice(offset, offset + segmentSize);
const appendForm = new FormData();
appendForm.append('command', 'APPEND');
appendForm.append('media_id', mediaId);
appendForm.append('segment_index', segmentIndex.toString());
appendForm.append('media', new Blob([chunk]));
const appendResponse = await fetch(uploadUrl, {
method: 'POST',
headers,
body: appendForm,
});
if (!appendResponse.ok) {
throw new Error(await appendResponse.text());
}
segmentIndex++;
}
// Finalize upload
const finalizeParams = new URLSearchParams();
finalizeParams.append('command', 'FINALIZE');
finalizeParams.append('media_id', mediaId);
const finalizeResponse = await fetch(uploadUrl, {
method: 'POST',
headers,
body: finalizeParams,
});
if (!finalizeResponse.ok) {
throw new Error(await finalizeResponse.text());
}
const finalizeData = await finalizeResponse.json();
// Check processing status for videos
if (finalizeData.processing_info) {
await checkUploadStatus(mediaId);
}
return mediaId;
}
// Function to check upload status
async function checkUploadStatus(mediaId: string): Promise<void> {
let processing = true;
while (processing) {
await new Promise((resolve) => setTimeout(resolve, 5000)); // Wait 5 seconds
const statusParams = new URLSearchParams();
statusParams.append('command', 'STATUS');
statusParams.append('media_id', mediaId);
const statusResponse = await fetch(
`${uploadUrl}?${statusParams.toString()}`,
{
method: 'GET',
headers,
},
);
if (!statusResponse.ok) {
throw new Error(await statusResponse.text());
}
const statusData = await statusResponse.json();
const state = statusData.processing_info.state;
if (state === 'succeeded') {
processing = false;
} else if (state === 'failed') {
throw new Error('Video processing failed');
}
}
}
}
// Function to create a quote tweet
export async function createQuoteTweetRequest(
text: string,
quotedTweetId: string,
auth: TwitterAuth,
mediaData?: { data: Buffer; mediaType: string }[],
) {
const onboardingTaskUrl = 'https://api.twitter.com/1.1/onboarding/task.json';
// Retrieve necessary cookies and tokens
const cookies = await auth.cookieJar().getCookies(onboardingTaskUrl);
const xCsrfToken = cookies.find((cookie) => cookie.key === 'ct0');
const headers = new Headers({
authorization: `Bearer ${(auth as any).bearerToken}`,
cookie: await auth.cookieJar().getCookieString(onboardingTaskUrl),
'content-type': 'application/json',
'User-Agent':
'Mozilla/5.0 (Linux; Android 11; Nokia G20) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.88 Mobile Safari/537.36',
'x-guest-token': (auth as any).guestToken,
'x-twitter-auth-type': 'OAuth2Client',
'x-twitter-active-user': 'yes',
'x-csrf-token': xCsrfToken?.value as string,
});
// Construct variables for the GraphQL request
const variables: Record<string, any> = {
tweet_text: text,
dark_request: false,
attachment_url: `https://twitter.com/twitter/status/${quotedTweetId}`,
media: {
media_entities: [],
possibly_sensitive: false,
},
semantic_annotation_ids: [],
};
// Handle media uploads if any media data is provided
if (mediaData && mediaData.length > 0) {
const mediaIds = await Promise.all(
mediaData.map(({ data, mediaType }) =>
uploadMedia(data, auth, mediaType),
),
);
variables.media.media_entities = mediaIds.map((id) => ({
media_id: id,
tagged_users: [],
}));
}
// Send the GraphQL request to create a quote tweet
const response = await fetch(
'https://twitter.com/i/api/graphql/a1p9RWpkYKBjWv_I3WzS-A/CreateTweet',
{
headers,
body: JSON.stringify({
variables,
features: {
interactive_text_enabled: true,
longform_notetweets_inline_media_enabled: false,
responsive_web_text_conversations_enabled: false,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled:
false,
vibe_api_enabled: false,
rweb_lists_timeline_redesign_enabled: true,
responsive_web_graphql_exclude_directive_enabled: true,
verified_phone_label_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled:
false,
tweetypie_unmention_optimization_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
tweet_awards_web_tipping_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
longform_notetweets_rich_text_read_enabled: true,
responsive_web_enhance_cards_enabled: false,
subscriptions_verification_info_enabled: true,
subscriptions_verification_info_reason_enabled: true,
subscriptions_verification_info_verified_since_enabled: true,
super_follow_badge_privacy_enabled: false,
super_follow_exclusive_tweet_notifications_enabled: false,
super_follow_tweet_api_enabled: false,
super_follow_user_api_enabled: false,
android_graphql_skip_api_media_color_palette: false,
creator_subscriptions_subscription_count_enabled: false,
blue_business_profile_image_shape_enabled: false,
unified_cards_ad_metadata_container_dynamic_card_content_query_enabled:
false,
rweb_video_timestamps_enabled: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: false,
},
fieldToggles: {},
}),
method: 'POST',
},
);
// Update the cookie jar with any new cookies from the response
await updateCookieJar(auth.cookieJar(), response.headers);
// Check for errors in the response
if (!response.ok) {
throw new Error(await response.text());
}
return response;
}
/**
* Likes a tweet with the given tweet ID.
* @param tweetId The ID of the tweet to like.
* @param auth The authentication object.
* @returns A promise that resolves when the tweet is liked.
*/
export async function likeTweet(
tweetId: string,
auth: TwitterAuth,
): Promise<void> {
// Prepare the GraphQL endpoint and payload
const likeTweetUrl =
'https://twitter.com/i/api/graphql/lI07N6Otwv1PhnEgXILM7A/FavoriteTweet';
// Retrieve necessary cookies and tokens
const cookies = await auth.cookieJar().getCookies(likeTweetUrl);
const xCsrfToken = cookies.find((cookie) => cookie.key === 'ct0');
const headers = new Headers({
authorization: `Bearer ${(auth as any).bearerToken}`,
cookie: await auth.cookieJar().getCookieString(likeTweetUrl),
'content-type': 'application/json',
'x-guest-token': (auth as any).guestToken,
'x-twitter-auth-type': 'OAuth2Client',
'x-twitter-active-user': 'yes',
'x-csrf-token': xCsrfToken?.value as string,
});
const payload = {
variables: {
tweet_id: tweetId,
},
};
// Send the POST request to like the tweet
const response = await fetch(likeTweetUrl, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
// Update the cookie jar with any new cookies from the response
await updateCookieJar(auth.cookieJar(), response.headers);
// Check for errors in the response
if (!response.ok) {
throw new Error(await response.text());
}
}
/**
* Retweets a tweet with the given tweet ID.
* @param tweetId The ID of the tweet to retweet.
* @param auth The authentication object.
* @returns A promise that resolves when the tweet is retweeted.
*/
export async function retweet(
tweetId: string,
auth: TwitterAuth,
): Promise<void> {
// Prepare the GraphQL endpoint and payload
const retweetUrl =
'https://twitter.com/i/api/graphql/ojPdsZsimiJrUGLR1sjUtA/CreateRetweet';
// Retrieve necessary cookies and tokens
const cookies = await auth.cookieJar().getCookies(retweetUrl);
const xCsrfToken = cookies.find((cookie) => cookie.key === 'ct0');
const headers = new Headers({
authorization: `Bearer ${(auth as any).bearerToken}`,
cookie: await auth.cookieJar().getCookieString(retweetUrl),
'content-type': 'application/json',
'x-guest-token': (auth as any).guestToken,
'x-twitter-auth-type': 'OAuth2Client',
'x-twitter-active-user': 'yes',
'x-csrf-token': xCsrfToken?.value as string,
});
const payload = {
variables: {
tweet_id: tweetId,
dark_request: false,
},
};
// Send the POST request to retweet the tweet
const response = await fetch(retweetUrl, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
// Update the cookie jar with any new cookies from the response
await updateCookieJar(auth.cookieJar(), response.headers);
// Check for errors in the response
if (!response.ok) {
throw new Error(await response.text());
}
}
export async function createCreateLongTweetRequest(
text: string,
auth: TwitterAuth,
tweetId?: string,
mediaData?: { data: Buffer; mediaType: string }[],
) {
// URL for the long tweet endpoint
const url =
'https://x.com/i/api/graphql/YNXM2DGuE2Sff6a2JD3Ztw/CreateNoteTweet';
const onboardingTaskUrl = 'https://api.twitter.com/1.1/onboarding/task.json';
const cookies = await auth.cookieJar().getCookies(onboardingTaskUrl);
const xCsrfToken = cookies.find((cookie) => cookie.key === 'ct0');
//@ ts-expect-error - This is a private API.
const headers = new Headers({
authorization: `Bearer ${(auth as any).bearerToken}`,
cookie: await auth.cookieJar().getCookieString(onboardingTaskUrl),
'content-type': 'application/json',
'User-Agent':
'Mozilla/5.0 (Linux; Android 11; Nokia G20) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.88 Mobile Safari/537.36',
'x-guest-token': (auth as any).guestToken,
'x-twitter-auth-type': 'OAuth2Client',
'x-twitter-active-user': 'yes',
'x-twitter-client-language': 'en',
'x-csrf-token': xCsrfToken?.value as string,
});
const variables: Record<string, any> = {
tweet_text: text,
dark_request: false,
media: {
media_entities: [],
possibly_sensitive: false,
},
semantic_annotation_ids: [],
};
if (mediaData && mediaData.length > 0) {
const mediaIds = await Promise.all(
mediaData.map(({ data, mediaType }) =>
uploadMedia(data, auth, mediaType),
),
);
variables.media.media_entities = mediaIds.map((id) => ({
media_id: id,
tagged_users: [],
}));
}
if (tweetId) {
variables.reply = { in_reply_to_tweet_id: tweetId };
}
const features = {
premium_content_api_read_enabled: false,
communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_grok_analyze_button_fetch_trends_enabled: true,
responsive_web_edit_tweet_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
tweet_awards_web_tipping_enabled: false,
creator_subscriptions_quote_tweet_preview_enabled: false,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true,
profile_label_improvements_pcf_label_in_post_enabled: false,
rweb_tipjar_consumption_enabled: true,
responsive_web_graphql_exclude_directive_enabled: true,
verified_phone_label_enabled: false,
articles_preview_enabled: true,
rweb_video_timestamps_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled:
true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_enhance_cards_enabled: false,
};
const response = await fetch(url, {
headers,
body: JSON.stringify({
variables,
features,
queryId: 'YNXM2DGuE2Sff6a2JD3Ztw',
}),
method: 'POST',
});
await updateCookieJar(auth.cookieJar(), response.headers);
// check for errors
if (!response.ok) {
throw new Error(await response.text());
}
return response;
}