diff --git a/README.md b/README.md index 38d99197..58b03b30 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ Rettiwt-API can be used with or without logging in to Twitter. As such, the two - Tweet Unschedule - User Affiliates - User Analytics (Only for Premium accounts) + - User About Profile (by username) - User Bookmarks - User Bookmark Folders - User Bookmark Folder Tweets @@ -517,6 +518,7 @@ So far, the following operations are supported: - [Getting the list of users affiliated with the given user](https://rishikant181.github.io/Rettiwt-API/classes/UserService.html#affiliates) - [Getting the analytics of the logged-in user (premium accounts only)](https://rishikant181.github.io/Rettiwt-API/classes/UserService.html#analytics) +- [Getting the about profile of a user](https://rishikant181.github.io/Rettiwt-API/classes/UserService.html#about) - [Getting the list of tweets bookmarked by the logged-in user](https://rishikant181.github.io/Rettiwt-API/classes/UserService.html#bookmarks) - [Getting the list of bookmark folders of the logged-in user](https://rishikant181.github.io/Rettiwt-API/classes/UserService.html#bookmarkFolders) - [Getting the list of tweets in a specific bookmark folder](https://rishikant181.github.io/Rettiwt-API/classes/UserService.html#bookmarkFolderTweets) diff --git a/src/collections/Extractors.ts b/src/collections/Extractors.ts index c82f1482..cb2a0145 100644 --- a/src/collections/Extractors.ts +++ b/src/collections/Extractors.ts @@ -9,6 +9,7 @@ import { Notification } from '../models/data/Notification'; import { Space } from '../models/data/Space'; import { Tweet } from '../models/data/Tweet'; import { User } from '../models/data/User'; +import { UserAbout } from '../models/data/UserAbout'; import { IConversationTimelineResponse } from '../types/raw/dm/Conversation'; import { IInboxInitialResponse } from '../types/raw/dm/InboxInitial'; import { IInboxTimelineResponse } from '../types/raw/dm/InboxTimeline'; @@ -35,6 +36,7 @@ import { ITweetUnlikeResponse } from '../types/raw/tweet/Unlike'; import { ITweetUnpostResponse } from '../types/raw/tweet/Unpost'; import { ITweetUnretweetResponse } from '../types/raw/tweet/Unretweet'; import { ITweetUnscheduleResponse } from '../types/raw/tweet/Unschedule'; +import { IUserAboutResponse } from '../types/raw/user/About'; import { IUserAffiliatesResponse } from '../types/raw/user/Affiliates'; import { IUserAnalyticsResponse } from '../types/raw/user/Analytics'; import { IUserBookmarkFoldersResponse } from '../types/raw/user/BookmarkFolders'; @@ -123,6 +125,7 @@ export const Extractors = { new CursoredData(response, BaseType.BOOKMARK_FOLDER), USER_BOOKMARK_FOLDER_TWEETS: (response: IUserBookmarkFolderTweetsResponse): CursoredData => new CursoredData(response, BaseType.TWEET), + USER_ABOUT_BY_USERNAME: (response: IUserAboutResponse): UserAbout | undefined => UserAbout.single(response), USER_DETAILS_BY_USERNAME: (response: IUserDetailsResponse): User | undefined => User.single(response), USER_DETAILS_BY_ID: (response: IUserDetailsResponse): User | undefined => User.single(response), USER_DETAILS_BY_IDS_BULK: (response: IUserDetailsBulkResponse, ids: string[]): User[] => diff --git a/src/collections/Groups.ts b/src/collections/Groups.ts index b00969a2..fdabc441 100644 --- a/src/collections/Groups.ts +++ b/src/collections/Groups.ts @@ -37,6 +37,7 @@ export const FetchResourcesGroup = [ ResourceType.USER_BOOKMARKS, ResourceType.USER_BOOKMARK_FOLDERS, ResourceType.USER_BOOKMARK_FOLDER_TWEETS, + ResourceType.USER_ABOUT_BY_USERNAME, ResourceType.USER_DETAILS_BY_USERNAME, ResourceType.USER_DETAILS_BY_ID, ResourceType.USER_DETAILS_BY_IDS_BULK, diff --git a/src/collections/Requests.ts b/src/collections/Requests.ts index bc8a9725..6b1127d0 100644 --- a/src/collections/Requests.ts +++ b/src/collections/Requests.ts @@ -70,6 +70,7 @@ export const Requests: { [key in keyof typeof ResourceType]: (args: IFetchArgs | USER_BOOKMARK_FOLDERS: (args: IFetchArgs) => UserRequests.bookmarkFolders(args.cursor), USER_BOOKMARK_FOLDER_TWEETS: (args: IFetchArgs) => UserRequests.bookmarkFolderTweets(args.id!, args.count, args.cursor), + USER_ABOUT_BY_USERNAME: (args: IFetchArgs) => UserRequests.aboutByUsername(args.id!), USER_DETAILS_BY_USERNAME: (args: IFetchArgs) => UserRequests.detailsByUsername(args.id!), USER_DETAILS_BY_ID: (args: IFetchArgs) => UserRequests.detailsById(args.id!), USER_DETAILS_BY_IDS_BULK: (args: IFetchArgs) => UserRequests.bulkDetailsByIds(args.ids!), diff --git a/src/commands/User.ts b/src/commands/User.ts index 91c1ef5a..3f65909c 100644 --- a/src/commands/User.ts +++ b/src/commands/User.ts @@ -110,6 +110,19 @@ function createUserCommand(rettiwt: Rettiwt): Command { } }); + // About + user.command('about') + .description('Fetch the about profile of the user with the given username') + .argument('', 'The username of the user') + .action(async (username: string) => { + try { + const about = await rettiwt.user.about(username); + output(about); + } catch (error) { + output(error); + } + }); + // Details user.command('details') .description('Fetch the details of the user with the given id/username') diff --git a/src/enums/Resource.ts b/src/enums/Resource.ts index 90f32b8a..d2b03034 100644 --- a/src/enums/Resource.ts +++ b/src/enums/Resource.ts @@ -50,6 +50,7 @@ export enum ResourceType { USER_BOOKMARKS = 'USER_BOOKMARKS', USER_BOOKMARK_FOLDERS = 'USER_BOOKMARK_FOLDERS', USER_BOOKMARK_FOLDER_TWEETS = 'USER_BOOKMARK_FOLDER_TWEETS', + USER_ABOUT_BY_USERNAME = 'USER_ABOUT_BY_USERNAME', USER_DETAILS_BY_USERNAME = 'USER_DETAILS_BY_USERNAME', USER_DETAILS_BY_ID = 'USER_DETAILS_BY_ID', USER_DETAILS_BY_IDS_BULK = 'USER_DETAILS_BY_IDS_BULK', diff --git a/src/index.ts b/src/index.ts index 87f0e511..1064f867 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,7 @@ export * from './models/data/Notification'; export * from './models/data/Space'; export * from './models/data/Tweet'; export * from './models/data/User'; +export * from './models/data/UserAbout'; export * from './models/errors/TwitterError'; // REQUESTS @@ -61,6 +62,7 @@ export * from './types/data/Notification'; export * from './types/data/Space'; export * from './types/data/Tweet'; export * from './types/data/User'; +export * from './types/data/UserAbout'; export * from './types/errors/TwitterError'; export * from './types/params/Variables'; export { IAnalytics as IRawAnalytics } from './types/raw/base/Analytic'; @@ -102,6 +104,7 @@ export { ITweetUnlikeResponse as IRawTweetUnlikeResponse } from './types/raw/twe export { ITweetUnpostResponse as IRawTweetUnpostResponse } from './types/raw/tweet/Unpost'; export { ITweetUnretweetResponse as IRawTweetUnretweetResponse } from './types/raw/tweet/Unretweet'; export { ITweetUnscheduleResponse as ITRawTweetUnscheduleResponse } from './types/raw/tweet/Unschedule'; +export { IUserAboutResponse as IRawUserAboutResponse } from './types/raw/user/About'; export { IUserAffiliatesResponse as IRawUserAffiliatesResponse } from './types/raw/user/Affiliates'; export { IUserAnalyticsResponse as IRawUserAnalyticsResponse } from './types/raw/user/Analytics'; export { IUserBookmarkFoldersResponse as IRawUserBookmarkFoldersResponse } from './types/raw/user/BookmarkFolders'; diff --git a/src/models/data/UserAbout.ts b/src/models/data/UserAbout.ts new file mode 100644 index 00000000..93f1806b --- /dev/null +++ b/src/models/data/UserAbout.ts @@ -0,0 +1,161 @@ +import { LogActions } from '../../enums/Logging'; +import { LogService } from '../../services/internal/LogService'; +import { + IUserAbout, + IUserAboutProfile, + IUserAboutUsernameChanges, + IUserAboutVerificationInfo, +} from '../../types/data/UserAbout'; +import { IUserAboutResponse, IUserAboutResult } from '../../types/raw/user/About'; + +/* eslint-disable @typescript-eslint/naming-convention */ +type IRawUsernameChanges = { + count?: string; + last_changed_at_msec?: string; +}; +/* eslint-enable @typescript-eslint/naming-convention */ + +/** + * The about profile details of a single user. + * + * @public + */ +export class UserAbout implements IUserAbout { + /** The raw about profile details. */ + private readonly _raw: IUserAboutResult; + + public aboutProfile?: IUserAboutProfile; + public createdAt: string; + public fullName: string; + public id: string; + public isProtected?: boolean; + public isVerified: boolean; + public profileImage: string; + public profileImageShape?: string; + public userName: string; + public verificationInfo?: IUserAboutVerificationInfo; + + /** + * @param user - The raw about profile details. + */ + public constructor(user: IUserAboutResult) { + this._raw = { ...user }; + + this.id = user.rest_id ?? user.id ?? ''; + this.userName = user.core?.screen_name ?? ''; + this.fullName = user.core?.name ?? ''; + this.createdAt = new Date(user.core?.created_at ?? 0).toISOString(); + this.profileImage = user.avatar?.image_url ?? ''; + this.profileImageShape = user.profile_image_shape; + this.isVerified = user.is_blue_verified ?? false; + this.isProtected = user.privacy?.protected; + this.aboutProfile = UserAbout._buildAboutProfile(user); + this.verificationInfo = UserAbout._buildVerificationInfo(user); + } + + /** The raw about profile details. */ + public get raw(): IUserAboutResult { + return { ...this._raw }; + } + + private static _buildAboutProfile(user: IUserAboutResult): IUserAboutProfile | undefined { + const profile = user.about_profile; + + if (!profile) { + return undefined; + } + + const usernameChanges = UserAbout._buildUsernameChanges(profile.username_changes); + + return { + createdCountryAccurate: profile.created_country_accurate, + accountBasedIn: profile.account_based_in, + locationAccurate: profile.location_accurate, + learnMoreUrl: profile.learn_more_url, + source: profile.source, + usernameChanges: usernameChanges, + }; + } + + private static _buildUsernameChanges(changes?: IRawUsernameChanges): IUserAboutUsernameChanges | undefined { + if (!changes) { + return undefined; + } + + return { + count: UserAbout._toNumber(changes.count), + lastChangedAt: UserAbout._toIsoFromMsec(changes.last_changed_at_msec), + }; + } + + private static _buildVerificationInfo(user: IUserAboutResult): IUserAboutVerificationInfo | undefined { + const info = user.verification_info; + + if (!info) { + return undefined; + } + + return { + isIdentityVerified: info.is_identity_verified, + verifiedSince: UserAbout._toIsoFromMsec(info.reason?.verified_since_msec), + }; + } + + private static _toIsoFromMsec(value?: string | number): string | undefined { + const parsed = UserAbout._toNumber(value); + + return parsed === undefined ? undefined : new Date(parsed).toISOString(); + } + + private static _toNumber(value?: string | number): number | undefined { + if (value === undefined || value === null) { + return undefined; + } + + const parsed = typeof value === 'number' ? value : Number(value); + + return Number.isFinite(parsed) ? parsed : undefined; + } + + /** + * Extracts and deserializes a single target user about profile from the given raw response data. + * + * @param response - The raw response data. + * + * @returns The target deserialized user about profile. + */ + public static single(response: NonNullable): UserAbout | undefined { + const result = (response as IUserAboutResponse)?.data?.user_result_by_screen_name?.result; + + if (!result || result.__typename !== 'User') { + LogService.log(LogActions.WARNING, { + action: LogActions.DESERIALIZE, + message: `User not found, skipping`, + }); + return undefined; + } + + // Logging + LogService.log(LogActions.DESERIALIZE, { id: result.rest_id ?? result.id }); + + return new UserAbout(result); + } + + /** + * @returns A serializable JSON representation of `this` object. + */ + public toJSON(): IUserAbout { + return { + id: this.id, + userName: this.userName, + fullName: this.fullName, + createdAt: this.createdAt, + profileImage: this.profileImage, + profileImageShape: this.profileImageShape, + isVerified: this.isVerified, + isProtected: this.isProtected, + aboutProfile: this.aboutProfile, + verificationInfo: this.verificationInfo, + }; + } +} diff --git a/src/requests/User.ts b/src/requests/User.ts index f719fcca..4f38adfe 100644 --- a/src/requests/User.ts +++ b/src/requests/User.ts @@ -11,6 +11,20 @@ import { IProfileUpdateOptions } from '../types/args/ProfileArgs'; * @public */ export class UserRequests { + /** + * @param userName - The username of the user whose about profile is to be fetched. + */ + public static aboutByUsername(userName: string): AxiosRequestConfig { + return { + method: 'get', + url: 'https://x.com/i/api/graphql/zs_jFPFT78rBpXv9Z3U2YQ/AboutAccountQuery', + params: { + variables: JSON.stringify({ screenName: userName }), + }, + paramsSerializer: { encode: encodeURIComponent }, + }; + } + /** * @param id - The id of the user whose affiliates are to be fetched. * @param count - The number of affiliates to fetch. Only works as a lower limit when used with a cursor. diff --git a/src/services/public/UserService.ts b/src/services/public/UserService.ts index ca3bb714..8cb3882c 100644 --- a/src/services/public/UserService.ts +++ b/src/services/public/UserService.ts @@ -9,8 +9,10 @@ import { List } from '../../models/data/List'; import { Notification } from '../../models/data/Notification'; import { Tweet } from '../../models/data/Tweet'; import { User } from '../../models/data/User'; +import { UserAbout } from '../../models/data/UserAbout'; import { RettiwtConfig } from '../../models/RettiwtConfig'; import { IProfileUpdateOptions } from '../../types/args/ProfileArgs'; +import { IUserAboutResponse } from '../../types/raw/user/About'; import { IUserAffiliatesResponse } from '../../types/raw/user/Affiliates'; import { IUserAnalyticsResponse } from '../../types/raw/user/Analytics'; import { IUserBookmarkFoldersResponse } from '../../types/raw/user/BookmarkFolders'; @@ -52,6 +54,47 @@ export class UserService extends FetcherService { super(config); } + /** + * Get the about profile of a user. + * + * @param userName - The username/screenname of the target user. + * + * @returns The about profile of the user. + * + * @example + * + * ```ts + * import { Rettiwt } from 'rettiwt-api'; + * + * // Creating a new Rettiwt instance using the given 'API_KEY' + * const rettiwt = new Rettiwt({ apiKey: API_KEY }); + * + * // Fetching the about profile of the User with username 'user1' or '@user1' + * rettiwt.user.about('user1') // or @user1 + * .then(res => { + * console.log(res); + * }) + * .catch(err => { + * console.log(err); + * }); + * ``` + */ + public async about(userName: string): Promise { + const resource = ResourceType.USER_ABOUT_BY_USERNAME; + + if (userName.startsWith('@')) { + userName = userName.slice(1); + } + + // Fetching raw about profile + const response = await this.request(resource, { id: userName }); + + // Deserializing response + const data = Extractors[resource](response); + + return data; + } + /** * Get the list affiliates of a user. * diff --git a/src/types/args/FetchArgs.ts b/src/types/args/FetchArgs.ts index 564f90b6..87058581 100644 --- a/src/types/args/FetchArgs.ts +++ b/src/types/args/FetchArgs.ts @@ -71,7 +71,7 @@ export interface IFetchArgs { * * @remarks * - Required for all resources except {@link ResourceType.TWEET_SEARCH} and {@link ResourceType.USER_TIMELINE_RECOMMENDED}. - * - For {@link ResourceType.USER_DETAILS_BY_USERNAME} and {@link ResourceType.USER_SEARCH}, can be alphanumeric, while for others, is strictly numeric. + * - For {@link ResourceType.USER_DETAILS_BY_USERNAME}, {@link ResourceType.USER_ABOUT_BY_USERNAME}, and {@link ResourceType.USER_SEARCH}, can be alphanumeric, while for others, is strictly numeric. */ id?: string; diff --git a/src/types/data/UserAbout.ts b/src/types/data/UserAbout.ts new file mode 100644 index 00000000..5d4b5447 --- /dev/null +++ b/src/types/data/UserAbout.ts @@ -0,0 +1,87 @@ +/** + * The about profile details of a single user. + * + * @public + */ +export interface IUserAbout { + /** The rest id of the user. */ + id: string; + + /** The username/screenname of the user. */ + userName: string; + + /** The full name of the user. */ + fullName: string; + + /** The creation date of user's account. */ + createdAt: string; + + /** The url of the profile image. */ + profileImage: string; + + /** The shape of the profile image. */ + profileImageShape?: string; + + /** Whether the account is verified or not. */ + isVerified: boolean; + + /** Whether the account is protected. */ + isProtected?: boolean; + + /** About profile details of the user. */ + aboutProfile?: IUserAboutProfile; + + /** Verification metadata of the user. */ + verificationInfo?: IUserAboutVerificationInfo; +} + +/** + * About profile information for a user. + * + * @public + */ +export interface IUserAboutProfile { + /** Whether the created country is accurate. */ + createdCountryAccurate?: boolean; + + /** The country where the account is based. */ + accountBasedIn?: string; + + /** Whether the location is accurate. */ + locationAccurate?: boolean; + + /** The help URL for verified accounts. */ + learnMoreUrl?: string; + + /** The source platform of the account. */ + source?: string; + + /** Username change metadata for the user. */ + usernameChanges?: IUserAboutUsernameChanges; +} + +/** + * Username change metadata for a user. + * + * @public + */ +export interface IUserAboutUsernameChanges { + /** The number of username changes. */ + count?: number; + + /** The last time the username was changed. */ + lastChangedAt?: string; +} + +/** + * Verification metadata for a user. + * + * @public + */ +export interface IUserAboutVerificationInfo { + /** Whether the user's identity is verified. */ + isIdentityVerified?: boolean; + + /** When the account was verified. */ + verifiedSince?: string; +} diff --git a/src/types/raw/user/About.ts b/src/types/raw/user/About.ts new file mode 100644 index 00000000..60eac1d5 --- /dev/null +++ b/src/types/raw/user/About.ts @@ -0,0 +1,77 @@ +/* eslint-disable */ + +/** + * The raw data received when fetching the about profile of the given user. + * + * @public + */ +export interface IUserAboutResponse { + data: Data; +} + +interface Data { + user_result_by_screen_name: UserResultByScreenName; +} + +interface UserResultByScreenName { + result: IUserAboutResult; + id: string; +} + +export interface IUserAboutResult { + __typename: string; + id: string; + rest_id: string; + avatar?: Avatar; + core?: Core; + profile_image_shape?: string; + verification?: Verification; + affiliates_highlighted_label?: unknown; + is_blue_verified?: boolean; + privacy?: Privacy; + about_profile?: AboutProfile; + verification_info?: VerificationInfo; + identity_profile_labels_highlighted_label?: unknown; +} + +interface Avatar { + image_url: string; +} + +interface Core { + created_at: string; + name: string; + screen_name: string; +} + +interface Verification { + verified: boolean; +} + +interface Privacy { + protected: boolean; +} + +interface AboutProfile { + created_country_accurate?: boolean; + account_based_in?: string; + location_accurate?: boolean; + learn_more_url?: string; + source?: string; + username_changes?: UsernameChanges; +} + +interface UsernameChanges { + count?: string; + last_changed_at_msec?: string; +} + +interface VerificationInfo { + reason?: VerificationReason; + id?: string; + is_identity_verified?: boolean; +} + +interface VerificationReason { + verified_since_msec?: string; +}