UNPKG

@the-convocation/twitter-scraper

Version:

A port of n0madic/twitter-scraper to Node.js.

1 lines 210 kB
{"version":3,"file":"index.cjs","sources":["../../../src/errors.ts","../../../src/rate-limit.ts","../../../src/platform/index.ts","../../../src/requests.ts","../../../src/api.ts","../../../src/auth.ts","../../../src/auth-user.ts","../../../src/api-data.ts","../../../src/profile.ts","../../../src/timeline-async.ts","../../../src/type-util.ts","../../../src/timeline-tweet-util.ts","../../../src/timeline-v2.ts","../../../src/timeline-search.ts","../../../src/search.ts","../../../src/timeline-relationship.ts","../../../src/relationships.ts","../../../src/trends.ts","../../../src/timeline-list.ts","../../../src/tweets.ts","../../../src/scraper.ts","../../../src/platform/node/randomize-ciphers.ts","../../../src/platform/node/index.ts"],"sourcesContent":["export class ApiError extends Error {\n constructor(readonly response: Response, readonly data: any) {\n super(\n `Response status: ${response.status} | headers: ${JSON.stringify(\n headersToString(response.headers),\n )} | data: ${typeof data === 'string' ? data : JSON.stringify(data)}`,\n );\n }\n\n static async fromResponse(response: Response) {\n // Try our best to parse the result, but don't bother if we can't\n let data: string | object | undefined = undefined;\n try {\n if (response.headers.get('content-type')?.includes('application/json')) {\n data = await response.json();\n } else {\n data = await response.text();\n }\n } catch {\n try {\n data = await response.text();\n } catch {}\n }\n\n return new ApiError(response, data);\n }\n}\n\nfunction headersToString(headers: Headers): string {\n const result: string[] = [];\n headers.forEach((value, key) => {\n result.push(`${key}: ${value}`);\n });\n return result.join('\\n');\n}\n\nexport class AuthenticationError extends Error {\n constructor(message?: string) {\n super(message || 'Authentication failed');\n this.name = 'AuthenticationError';\n }\n}\n\nexport interface TwitterApiErrorPosition {\n line: number;\n column: number;\n}\n\nexport interface TwitterApiErrorTraceInfo {\n trace_id: string;\n}\n\nexport interface TwitterApiErrorExtensions {\n code?: number;\n kind?: string;\n name?: string;\n source?: string;\n tracing?: TwitterApiErrorTraceInfo;\n}\n\nexport interface TwitterApiErrorRaw extends TwitterApiErrorExtensions {\n message?: string;\n locations?: TwitterApiErrorPosition[];\n path?: string[];\n extensions?: TwitterApiErrorExtensions;\n}\n","import { FetchParameters } from './api-types';\nimport { ApiError } from './errors';\nimport debug from 'debug';\n\nconst log = debug('twitter-scraper:rate-limit');\n\n/**\n * Information about a rate-limiting event. Both the request and response\n * information are provided.\n */\nexport interface RateLimitEvent {\n /** The complete arguments that were passed to the fetch function. */\n fetchParameters: FetchParameters;\n /** The failing HTTP response. */\n response: Response;\n}\n\n/**\n * The public interface for all rate-limiting strategies. Library consumers are\n * welcome to provide their own implementations of this interface in the Scraper\n * constructor options.\n *\n * The {@link RateLimitEvent} object contains both the request and response\n * information associated with the event.\n *\n * @example\n * import { Scraper, RateLimitStrategy } from \"@the-convocation/twitter-scraper\";\n *\n * // A custom rate-limiting implementation that just logs request/response information.\n * class ConsoleLogRateLimitStrategy implements RateLimitStrategy {\n * async onRateLimit(event: RateLimitEvent): Promise<void> {\n * console.log(event.fetchParameters, event.response);\n * }\n * }\n *\n * const scraper = new Scraper({\n * rateLimitStrategy: new ConsoleLogRateLimitStrategy(),\n * });\n */\nexport interface RateLimitStrategy {\n /**\n * Called when the scraper is rate limited.\n * @param event The event information, including the request and response info.\n */\n onRateLimit(event: RateLimitEvent): Promise<void>;\n}\n\n/**\n * A rate-limiting strategy that simply waits for the current rate limit period to expire.\n * This has been known to take up to 13 minutes, in some cases.\n */\nexport class WaitingRateLimitStrategy implements RateLimitStrategy {\n async onRateLimit({ response: res }: RateLimitEvent): Promise<void> {\n /*\n Known headers at this point:\n - x-rate-limit-limit: Maximum number of requests per time period?\n - x-rate-limit-reset: UNIX timestamp when the current rate limit will be reset.\n - x-rate-limit-remaining: Number of requests remaining in current time period?\n */\n const xRateLimitLimit = res.headers.get('x-rate-limit-limit');\n const xRateLimitRemaining = res.headers.get('x-rate-limit-remaining');\n const xRateLimitReset = res.headers.get('x-rate-limit-reset');\n\n log(\n `Rate limit event: limit=${xRateLimitLimit}, remaining=${xRateLimitRemaining}, reset=${xRateLimitReset}`,\n );\n\n if (xRateLimitRemaining == '0' && xRateLimitReset) {\n const currentTime = new Date().valueOf() / 1000;\n const timeDeltaMs = 1000 * (parseInt(xRateLimitReset) - currentTime);\n\n // I have seen this block for 800s (~13 *minutes*)\n await new Promise((resolve) => setTimeout(resolve, timeDeltaMs));\n }\n }\n}\n\n/**\n * A rate-limiting strategy that throws an {@link ApiError} when a rate limiting event occurs.\n */\nexport class ErrorRateLimitStrategy implements RateLimitStrategy {\n async onRateLimit({ response: res }: RateLimitEvent): Promise<void> {\n throw await ApiError.fromResponse(res);\n }\n}\n","import { PlatformExtensions, genericPlatform } from './platform-interface';\n\nexport * from './platform-interface';\n\ndeclare const PLATFORM_NODE: boolean;\ndeclare const PLATFORM_NODE_JEST: boolean;\n\nexport class Platform implements PlatformExtensions {\n async randomizeCiphers() {\n const platform = await Platform.importPlatform();\n await platform?.randomizeCiphers();\n }\n\n private static async importPlatform(): Promise<null | PlatformExtensions> {\n if (PLATFORM_NODE) {\n const { platform } = await import('./node/index.js');\n return platform as PlatformExtensions;\n } else if (PLATFORM_NODE_JEST) {\n // Jest gets unhappy when using an await import here, so we just use require instead.\n // eslint-disable-next-line @typescript-eslint/no-var-requires\n const { platform } = require('./node');\n return platform as PlatformExtensions;\n }\n\n return genericPlatform;\n }\n}\n","import { Cookie, CookieJar } from 'tough-cookie';\nimport setCookie from 'set-cookie-parser';\nimport type { Headers as HeadersPolyfill } from 'headers-polyfill';\n\n/**\n * Updates a cookie jar with the Set-Cookie headers from the provided Headers instance.\n * @param cookieJar The cookie jar to update.\n * @param headers The response headers to populate the cookie jar with.\n */\nexport async function updateCookieJar(\n cookieJar: CookieJar,\n headers: Headers | HeadersPolyfill,\n) {\n const setCookieHeader = headers.get('set-cookie');\n if (setCookieHeader) {\n const cookies = setCookie.splitCookiesString(setCookieHeader);\n for (const cookie of cookies.map((c) => Cookie.parse(c))) {\n if (!cookie) continue;\n await cookieJar.setCookie(\n cookie,\n `${cookie.secure ? 'https' : 'http'}://${cookie.domain}${cookie.path}`,\n );\n }\n } else if (typeof document !== 'undefined') {\n for (const cookie of document.cookie.split(';')) {\n const hardCookie = Cookie.parse(cookie);\n if (hardCookie) {\n await cookieJar.setCookie(hardCookie, document.location.toString());\n }\n }\n }\n}\n","import { FetchParameters } from './api-types';\nimport { TwitterAuth } from './auth';\nimport { ApiError } from './errors';\nimport { Platform, PlatformExtensions } from './platform';\nimport { updateCookieJar } from './requests';\nimport { Headers } from 'headers-polyfill';\nimport debug from 'debug';\n\nconst log = debug('twitter-scraper:api');\n\nexport interface FetchTransformOptions {\n /**\n * Transforms the request options before a request is made. This executes after all of the default\n * parameters have been configured, and is stateless. It is safe to return new request options\n * objects.\n * @param args The request options.\n * @returns The transformed request options.\n */\n request: (\n ...args: FetchParameters\n ) => FetchParameters | Promise<FetchParameters>;\n\n /**\n * Transforms the response after a request completes. This executes immediately after the request\n * completes, and is stateless. It is safe to return a new response object.\n * @param response The response object.\n * @returns The transformed response object.\n */\n response: (response: Response) => Response | Promise<Response>;\n}\n\nexport const bearerToken =\n 'AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF';\n\nexport async function jitter(maxMs: number): Promise<void> {\n const jitter = Math.random() * maxMs;\n await new Promise((resolve) => setTimeout(resolve, jitter));\n}\n\n/**\n * An API result container.\n */\nexport type RequestApiResult<T> =\n | { success: true; value: T }\n | { success: false; err: Error };\n\n/**\n * Used internally to send HTTP requests to the Twitter API.\n * @internal\n * @param url - The URL to send the request to.\n * @param auth - The instance of {@link TwitterAuth} that will be used to authorize this request.\n * @param method - The HTTP method used when sending this request.\n */\nexport async function requestApi<T>(\n url: string,\n auth: TwitterAuth,\n method: 'GET' | 'POST' = 'GET',\n platform: PlatformExtensions = new Platform(),\n): Promise<RequestApiResult<T>> {\n log(`Making ${method} request to ${url}`);\n\n const headers = new Headers();\n await auth.installTo(headers, url);\n await platform.randomizeCiphers();\n\n let res: Response;\n do {\n const fetchParameters: FetchParameters = [\n url,\n {\n method,\n headers,\n credentials: 'include',\n },\n ];\n\n try {\n res = await auth.fetch(...fetchParameters);\n } catch (err) {\n if (!(err instanceof Error)) {\n throw err;\n }\n\n return {\n success: false,\n err: new Error('Failed to perform request.'),\n };\n }\n\n await updateCookieJar(auth.cookieJar(), res.headers);\n\n if (res.status === 429) {\n log('Rate limit hit, waiting for retry...');\n await auth.onRateLimit({\n fetchParameters: fetchParameters,\n response: res,\n });\n }\n } while (res.status === 429);\n\n if (!res.ok) {\n return {\n success: false,\n err: await ApiError.fromResponse(res),\n };\n }\n\n const value: T = await res.json();\n if (res.headers.get('x-rate-limit-incoming') == '0') {\n auth.deleteToken();\n return { success: true, value };\n } else {\n return { success: true, value };\n }\n}\n\n/** @internal */\nexport function addApiFeatures(o: object) {\n return {\n ...o,\n rweb_lists_timeline_redesign_enabled: true,\n responsive_web_graphql_exclude_directive_enabled: true,\n verified_phone_label_enabled: false,\n creator_subscriptions_tweet_preview_api_enabled: true,\n responsive_web_graphql_timeline_navigation_enabled: true,\n responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,\n tweetypie_unmention_optimization_enabled: true,\n responsive_web_edit_tweet_api_enabled: true,\n graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,\n view_counts_everywhere_api_enabled: true,\n longform_notetweets_consumption_enabled: true,\n tweet_awards_web_tipping_enabled: false,\n freedom_of_speech_not_reach_fetch_enabled: true,\n standardized_nudges_misinfo: true,\n longform_notetweets_rich_text_read_enabled: true,\n responsive_web_enhance_cards_enabled: false,\n subscriptions_verification_info_enabled: true,\n subscriptions_verification_info_reason_enabled: true,\n subscriptions_verification_info_verified_since_enabled: true,\n super_follow_badge_privacy_enabled: false,\n super_follow_exclusive_tweet_notifications_enabled: false,\n super_follow_tweet_api_enabled: false,\n super_follow_user_api_enabled: false,\n android_graphql_skip_api_media_color_palette: false,\n creator_subscriptions_subscription_count_enabled: false,\n blue_business_profile_image_shape_enabled: false,\n unified_cards_ad_metadata_container_dynamic_card_content_query_enabled:\n false,\n };\n}\n\nexport function addApiParams(\n params: URLSearchParams,\n includeTweetReplies: boolean,\n): URLSearchParams {\n params.set('include_profile_interstitial_type', '1');\n params.set('include_blocking', '1');\n params.set('include_blocked_by', '1');\n params.set('include_followed_by', '1');\n params.set('include_want_retweets', '1');\n params.set('include_mute_edge', '1');\n params.set('include_can_dm', '1');\n params.set('include_can_media_tag', '1');\n params.set('include_ext_has_nft_avatar', '1');\n params.set('include_ext_is_blue_verified', '1');\n params.set('include_ext_verified_type', '1');\n params.set('skip_status', '1');\n params.set('cards_platform', 'Web-12');\n params.set('include_cards', '1');\n params.set('include_ext_alt_text', 'true');\n params.set('include_ext_limited_action_results', 'false');\n params.set('include_quote_count', 'true');\n params.set('include_reply_count', '1');\n params.set('tweet_mode', 'extended');\n params.set('include_ext_collab_control', 'true');\n params.set('include_ext_views', 'true');\n params.set('include_entities', 'true');\n params.set('include_user_entities', 'true');\n params.set('include_ext_media_color', 'true');\n params.set('include_ext_media_availability', 'true');\n params.set('include_ext_sensitive_media_warning', 'true');\n params.set('include_ext_trusted_friends_metadata', 'true');\n params.set('send_error_codes', 'true');\n params.set('simple_quoted_tweet', 'true');\n params.set('include_tweet_replies', `${includeTweetReplies}`);\n params.set(\n 'ext',\n 'mediaStats,highlightedLabel,hasNftAvatar,voiceInfo,birdwatchPivot,enrichments,superFollowMetadata,unmentionInfo,editControl,collab_control,vibe',\n );\n return params;\n}\n","import { Cookie, CookieJar, MemoryCookieStore } from 'tough-cookie';\nimport { updateCookieJar } from './requests';\nimport { Headers } from 'headers-polyfill';\nimport fetch from 'cross-fetch';\nimport { FetchTransformOptions } from './api';\nimport {\n RateLimitEvent,\n RateLimitStrategy,\n WaitingRateLimitStrategy,\n} from './rate-limit';\nimport { AuthenticationError } from './errors';\nimport debug from 'debug';\n\nconst log = debug('twitter-scraper:auth');\n\nexport interface TwitterAuthOptions {\n fetch: typeof fetch;\n transform: Partial<FetchTransformOptions>;\n rateLimitStrategy: RateLimitStrategy;\n}\n\nexport interface TwitterAuth {\n fetch: typeof fetch;\n\n /**\n * How to behave when being rate-limited.\n * @param event The event information.\n */\n onRateLimit(event: RateLimitEvent): Promise<void>;\n\n /**\n * Returns the current cookie jar.\n */\n cookieJar(): CookieJar;\n\n /**\n * Returns if a user is logged-in to Twitter through this instance.\n * @returns `true` if a user is logged-in; otherwise `false`.\n */\n isLoggedIn(): Promise<boolean>;\n\n /**\n * Logs into a Twitter account.\n * @param username The username to log in with.\n * @param password The password to log in with.\n * @param email The email to log in with, if you have email confirmation enabled.\n * @param twoFactorSecret The secret to generate two factor authentication tokens with, if you have two factor authentication enabled.\n */\n login(\n username: string,\n password: string,\n email?: string,\n twoFactorSecret?: string,\n ): Promise<void>;\n\n /**\n * Logs out of the current session.\n */\n logout(): Promise<void>;\n\n /**\n * Deletes the current guest token token.\n */\n deleteToken(): void;\n\n /**\n * Returns if the authentication state has a token.\n * @returns `true` if the authentication state has a token; `false` otherwise.\n */\n hasToken(): boolean;\n\n /**\n * Returns the time that authentication was performed.\n * @returns The time at which the authentication token was created, or `null` if it hasn't been created yet.\n */\n authenticatedAt(): Date | null;\n\n /**\n * Installs the authentication information into a headers-like object. If needed, the\n * authentication token will be updated from the API automatically.\n * @param headers A Headers instance representing a request's headers.\n */\n installTo(headers: Headers, url: string): Promise<void>;\n}\n\n/**\n * Wraps the provided fetch function with transforms.\n * @param fetchFn The fetch function.\n * @param transform The transform options.\n * @returns The input fetch function, wrapped with the provided transforms.\n */\nfunction withTransform(\n fetchFn: typeof fetch,\n transform?: Partial<FetchTransformOptions>,\n): typeof fetch {\n return async (input, init) => {\n const fetchArgs = (await transform?.request?.(input, init)) ?? [\n input,\n init,\n ];\n const res = await fetchFn(...fetchArgs);\n return (await transform?.response?.(res)) ?? res;\n };\n}\n\n/**\n * A guest authentication token manager. Automatically handles token refreshes.\n */\nexport class TwitterGuestAuth implements TwitterAuth {\n protected bearerToken: string;\n protected jar: CookieJar;\n protected guestToken?: string;\n protected guestCreatedAt?: Date;\n protected rateLimitStrategy: RateLimitStrategy;\n\n fetch: typeof fetch;\n\n constructor(\n bearerToken: string,\n protected readonly options?: Partial<TwitterAuthOptions>,\n ) {\n this.fetch = withTransform(options?.fetch ?? fetch, options?.transform);\n this.rateLimitStrategy =\n options?.rateLimitStrategy ?? new WaitingRateLimitStrategy();\n this.bearerToken = bearerToken;\n this.jar = new CookieJar();\n }\n\n async onRateLimit(event: RateLimitEvent): Promise<void> {\n await this.rateLimitStrategy.onRateLimit(event);\n }\n\n cookieJar(): CookieJar {\n return this.jar;\n }\n\n isLoggedIn(): Promise<boolean> {\n return Promise.resolve(false);\n }\n\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n login(_username: string, _password: string, _email?: string): Promise<void> {\n return this.updateGuestToken();\n }\n\n logout(): Promise<void> {\n this.deleteToken();\n this.jar = new CookieJar();\n return Promise.resolve();\n }\n\n deleteToken() {\n delete this.guestToken;\n delete this.guestCreatedAt;\n }\n\n hasToken(): boolean {\n return this.guestToken != null;\n }\n\n authenticatedAt(): Date | null {\n if (this.guestCreatedAt == null) {\n return null;\n }\n\n return new Date(this.guestCreatedAt);\n }\n\n async installTo(headers: Headers): Promise<void> {\n if (this.shouldUpdate()) {\n await this.updateGuestToken();\n }\n\n const token = this.guestToken;\n if (token == null) {\n throw new AuthenticationError(\n 'Authentication token is null or undefined.',\n );\n }\n\n headers.set('authorization', `Bearer ${this.bearerToken}`);\n headers.set('x-guest-token', token);\n\n const cookies = await this.getCookies();\n const xCsrfToken = cookies.find((cookie) => cookie.key === 'ct0');\n if (xCsrfToken) {\n headers.set('x-csrf-token', xCsrfToken.value);\n }\n\n headers.set('cookie', await this.getCookieString());\n }\n\n protected async getCookies(): Promise<Cookie[]> {\n return this.jar.getCookies(this.getCookieJarUrl());\n }\n\n protected async getCookieString(): Promise<string> {\n const cookies = await this.getCookies();\n return cookies.map((cookie) => `${cookie.key}=${cookie.value}`).join('; ');\n }\n\n protected async removeCookie(key: string): Promise<void> {\n //@ts-expect-error don't care\n const store: MemoryCookieStore = this.jar.store;\n const cookies = await this.jar.getCookies(this.getCookieJarUrl());\n for (const cookie of cookies) {\n if (!cookie.domain || !cookie.path) continue;\n store.removeCookie(cookie.domain, cookie.path, key);\n\n if (typeof document !== 'undefined') {\n document.cookie = `${cookie.key}=; Max-Age=0; path=${cookie.path}; domain=${cookie.domain}`;\n }\n }\n }\n\n private getCookieJarUrl(): string {\n return typeof document !== 'undefined'\n ? document.location.toString()\n : 'https://x.com';\n }\n\n /**\n * Updates the authentication state with a new guest token from the Twitter API.\n */\n protected async updateGuestToken() {\n const guestActivateUrl = 'https://api.x.com/1.1/guest/activate.json';\n\n const headers = new Headers({\n Authorization: `Bearer ${this.bearerToken}`,\n Cookie: await this.getCookieString(),\n });\n\n log(`Making POST request to ${guestActivateUrl}`);\n\n const res = await this.fetch(guestActivateUrl, {\n method: 'POST',\n headers: headers,\n referrerPolicy: 'no-referrer',\n });\n\n await updateCookieJar(this.jar, res.headers);\n\n if (!res.ok) {\n throw new AuthenticationError(await res.text());\n }\n\n const o = await res.json();\n if (o == null || o['guest_token'] == null) {\n throw new AuthenticationError('guest_token not found.');\n }\n\n const newGuestToken = o['guest_token'];\n if (typeof newGuestToken !== 'string') {\n throw new AuthenticationError('guest_token was not a string.');\n }\n\n this.guestToken = newGuestToken;\n this.guestCreatedAt = new Date();\n }\n\n /**\n * Returns if the authentication token needs to be updated or not.\n * @returns `true` if the token needs to be updated; `false` otherwise.\n */\n private shouldUpdate(): boolean {\n return (\n !this.hasToken() ||\n (this.guestCreatedAt != null &&\n this.guestCreatedAt <\n new Date(new Date().valueOf() - 3 * 60 * 60 * 1000))\n );\n }\n}\n","import { TwitterAuthOptions, TwitterGuestAuth } from './auth';\nimport { requestApi } from './api';\nimport { CookieJar } from 'tough-cookie';\nimport { updateCookieJar } from './requests';\nimport { Headers } from 'headers-polyfill';\nimport { TwitterApiErrorRaw, AuthenticationError, ApiError } from './errors';\nimport { Type, type Static } from '@sinclair/typebox';\nimport { Check } from '@sinclair/typebox/value';\nimport * as OTPAuth from 'otpauth';\nimport { FetchParameters } from './api-types';\nimport debug from 'debug';\n\nconst log = debug('twitter-scraper:auth-user');\n\nexport interface TwitterUserAuthFlowInitRequest {\n flow_name: string;\n input_flow_data: Record<string, unknown>;\n subtask_versions: Record<string, number>;\n}\n\nexport interface TwitterUserAuthFlowSubtaskRequest {\n flow_token: string;\n subtask_inputs: ({\n subtask_id: string;\n } & Record<string, unknown>)[];\n}\n\nexport type TwitterUserAuthFlowRequest =\n | TwitterUserAuthFlowInitRequest\n | TwitterUserAuthFlowSubtaskRequest;\n\nexport interface TwitterUserAuthFlowResponse {\n errors?: TwitterApiErrorRaw[];\n flow_token?: string;\n status?: string;\n subtasks?: TwitterUserAuthSubtask[];\n}\n\ninterface TwitterUserAuthVerifyCredentials {\n errors?: TwitterApiErrorRaw[];\n}\n\nconst TwitterUserAuthSubtask = Type.Object({\n subtask_id: Type.String(),\n enter_text: Type.Optional(Type.Object({})),\n});\ntype TwitterUserAuthSubtask = Static<typeof TwitterUserAuthSubtask>;\n\nexport type FlowTokenResultSuccess = {\n status: 'success';\n response: TwitterUserAuthFlowResponse;\n};\n\nexport type FlowTokenResultError = {\n status: 'error';\n err: Error;\n};\n\nexport type FlowTokenResult = FlowTokenResultSuccess | FlowTokenResultError;\n\nexport interface TwitterUserAuthCredentials {\n username: string;\n password: string;\n email?: string;\n twoFactorSecret?: string;\n}\n\n/**\n * The API interface provided to custom subtask handlers for interacting with the Twitter authentication flow.\n * This interface allows handlers to send flow requests and access the current flow token.\n *\n * The API is passed to each subtask handler and provides methods necessary for implementing\n * custom authentication subtasks. It abstracts away the low-level details of communicating\n * with Twitter's authentication API.\n *\n * @example\n * ```typescript\n * import { Scraper, FlowSubtaskHandler } from \"@the-convocation/twitter-scraper\";\n *\n * // A custom subtask handler that implements a hypothetical example subtask\n * const exampleHandler: FlowSubtaskHandler = async (subtaskId, response, credentials, api) => {\n * // Process the example subtask somehow\n * const data = await processExampleTask();\n *\n * // Submit the processed data using the provided API\n * return await api.sendFlowRequest({\n * flow_token: api.getFlowToken(),\n * subtask_inputs: [{\n * subtask_id: subtaskId,\n * example_data: {\n * value: data,\n * link: \"next_link\"\n * }\n * }]\n * });\n * };\n *\n * const scraper = new Scraper();\n * scraper.registerAuthSubtaskHandler(\"ExampleSubtask\", exampleHandler);\n * ```\n */\nexport interface FlowSubtaskHandlerApi {\n /**\n * Send a flow request to the Twitter API.\n * @param request The request object containing flow token and subtask inputs\n * @returns The result of the flow task\n */\n sendFlowRequest: (\n request: TwitterUserAuthFlowRequest,\n ) => Promise<FlowTokenResult>;\n /**\n * Gets the current flow token.\n * @returns The current flow token\n */\n getFlowToken: () => string;\n}\n\n/**\n * A handler function for processing Twitter authentication flow subtasks.\n * Library consumers can implement and register custom handlers for new or\n * existing subtask types using the Scraper.registerAuthSubtaskHandler method.\n *\n * Each subtask handler is called when its corresponding subtask ID is encountered\n * during the authentication flow. The handler receives the subtask ID, the previous\n * response data, the user's credentials, and an API interface for interacting with\n * the authentication flow.\n *\n * Handlers should process their specific subtask and return either a successful response\n * or an error. Success responses typically lead to the next subtask in the flow, while\n * errors will halt the authentication process.\n *\n * @param subtaskId - The identifier of the subtask being handled\n * @param previousResponse - The complete response from the previous authentication flow step\n * @param credentials - The user's authentication credentials including username, password, etc.\n * @param api - An interface providing methods to interact with the authentication flow\n * @returns A promise resolving to either a successful flow response or an error\n *\n * @example\n * ```typescript\n * import { Scraper, FlowSubtaskHandler } from \"@the-convocation/twitter-scraper\";\n *\n * // Custom handler for a hypothetical verification subtask\n * const verificationHandler: FlowSubtaskHandler = async (\n * subtaskId,\n * response,\n * credentials,\n * api\n * ) => {\n * // Extract the verification data from the response\n * const verificationData = response.subtasks?.[0].exampleData?.value;\n * if (!verificationData) {\n * return {\n * status: 'error',\n * err: new Error('No verification data found in response')\n * };\n * }\n *\n * // Process the verification data somehow\n * const result = await processVerification(verificationData);\n *\n * // Submit the result using the flow API\n * return await api.sendFlowRequest({\n * flow_token: api.getFlowToken(),\n * subtask_inputs: [{\n * subtask_id: subtaskId,\n * example_verification: {\n * value: result,\n * link: \"next_link\"\n * }\n * }]\n * });\n * };\n *\n * const scraper = new Scraper();\n * scraper.registerAuthSubtaskHandler(\"ExampleVerificationSubtask\", verificationHandler);\n *\n * // Later, when logging in...\n * await scraper.login(\"username\", \"password\");\n * ```\n */\nexport type FlowSubtaskHandler = (\n subtaskId: string,\n previousResponse: TwitterUserAuthFlowResponse,\n credentials: TwitterUserAuthCredentials,\n api: FlowSubtaskHandlerApi,\n) => Promise<FlowTokenResult>;\n\n/**\n * A user authentication token manager.\n */\nexport class TwitterUserAuth extends TwitterGuestAuth {\n private readonly subtaskHandlers: Map<string, FlowSubtaskHandler> = new Map();\n\n constructor(bearerToken: string, options?: Partial<TwitterAuthOptions>) {\n super(bearerToken, options);\n this.initializeDefaultHandlers();\n }\n\n /**\n * Register a custom subtask handler or override an existing one\n * @param subtaskId The ID of the subtask to handle\n * @param handler The handler function that processes the subtask\n */\n registerSubtaskHandler(subtaskId: string, handler: FlowSubtaskHandler): void {\n this.subtaskHandlers.set(subtaskId, handler);\n }\n\n private initializeDefaultHandlers(): void {\n this.subtaskHandlers.set(\n 'LoginJsInstrumentationSubtask',\n this.handleJsInstrumentationSubtask.bind(this),\n );\n this.subtaskHandlers.set(\n 'LoginEnterUserIdentifierSSO',\n this.handleEnterUserIdentifierSSO.bind(this),\n );\n this.subtaskHandlers.set(\n 'LoginEnterAlternateIdentifierSubtask',\n this.handleEnterAlternateIdentifierSubtask.bind(this),\n );\n this.subtaskHandlers.set(\n 'LoginEnterPassword',\n this.handleEnterPassword.bind(this),\n );\n this.subtaskHandlers.set(\n 'AccountDuplicationCheck',\n this.handleAccountDuplicationCheck.bind(this),\n );\n this.subtaskHandlers.set(\n 'LoginTwoFactorAuthChallenge',\n this.handleTwoFactorAuthChallenge.bind(this),\n );\n this.subtaskHandlers.set('LoginAcid', this.handleAcid.bind(this));\n this.subtaskHandlers.set(\n 'LoginSuccessSubtask',\n this.handleSuccessSubtask.bind(this),\n );\n }\n\n async isLoggedIn(): Promise<boolean> {\n const res = await requestApi<TwitterUserAuthVerifyCredentials>(\n 'https://api.x.com/1.1/account/verify_credentials.json',\n this,\n );\n if (!res.success) {\n return false;\n }\n\n const { value: verify } = res;\n return verify && !verify.errors?.length;\n }\n\n async login(\n username: string,\n password: string,\n email?: string,\n twoFactorSecret?: string,\n ): Promise<void> {\n await this.updateGuestToken();\n\n const credentials: TwitterUserAuthCredentials = {\n username,\n password,\n email,\n twoFactorSecret,\n };\n\n let next: FlowTokenResult = await this.initLogin();\n while (next.status === 'success' && next.response.subtasks?.length) {\n const flowToken = next.response.flow_token;\n if (flowToken == null) {\n // Should never happen\n throw new Error('flow_token not found.');\n }\n\n const subtaskId = next.response.subtasks[0].subtask_id;\n const handler = this.subtaskHandlers.get(subtaskId);\n\n if (handler) {\n next = await handler(subtaskId, next.response, credentials, {\n sendFlowRequest: this.executeFlowTask.bind(this),\n getFlowToken: () => flowToken,\n });\n } else {\n throw new Error(`Unknown subtask ${subtaskId}`);\n }\n }\n if (next.status === 'error') {\n throw next.err;\n }\n }\n\n async logout(): Promise<void> {\n if (!this.hasToken()) {\n return;\n }\n\n try {\n await requestApi<void>(\n 'https://api.x.com/1.1/account/logout.json',\n this,\n 'POST',\n );\n } catch (error) {\n // Ignore errors during logout but still clean up state\n console.warn('Error during logout:', error);\n } finally {\n this.deleteToken();\n this.jar = new CookieJar();\n }\n }\n\n async installCsrfToken(headers: Headers): Promise<void> {\n const cookies = await this.getCookies();\n const xCsrfToken = cookies.find((cookie) => cookie.key === 'ct0');\n if (xCsrfToken) {\n headers.set('x-csrf-token', xCsrfToken.value);\n }\n }\n\n async installTo(headers: Headers): Promise<void> {\n headers.set('authorization', `Bearer ${this.bearerToken}`);\n headers.set('cookie', await this.getCookieString());\n await this.installCsrfToken(headers);\n }\n\n private async initLogin(): Promise<FlowTokenResult> {\n // Reset certain session-related cookies because Twitter complains sometimes if we don't\n this.removeCookie('twitter_ads_id=');\n this.removeCookie('ads_prefs=');\n this.removeCookie('_twitter_sess=');\n this.removeCookie('zipbox_forms_auth_token=');\n this.removeCookie('lang=');\n this.removeCookie('bouncer_reset_cookie=');\n this.removeCookie('twid=');\n this.removeCookie('twitter_ads_idb=');\n this.removeCookie('email_uid=');\n this.removeCookie('external_referer=');\n this.removeCookie('ct0=');\n this.removeCookie('aa_u=');\n this.removeCookie('__cf_bm=');\n\n return await this.executeFlowTask({\n flow_name: 'login',\n input_flow_data: {\n flow_context: {\n debug_overrides: {},\n start_location: {\n location: 'unknown',\n },\n },\n },\n subtask_versions: {\n action_list: 2,\n alert_dialog: 1,\n app_download_cta: 1,\n check_logged_in_account: 1,\n choice_selection: 3,\n contacts_live_sync_permission_prompt: 0,\n cta: 7,\n email_verification: 2,\n end_flow: 1,\n enter_date: 1,\n enter_email: 2,\n enter_password: 5,\n enter_phone: 2,\n enter_recaptcha: 1,\n enter_text: 5,\n enter_username: 2,\n generic_urt: 3,\n in_app_notification: 1,\n interest_picker: 3,\n js_instrumentation: 1,\n menu_dialog: 1,\n notifications_permission_prompt: 2,\n open_account: 2,\n open_home_timeline: 1,\n open_link: 1,\n phone_verification: 4,\n privacy_options: 1,\n security_key: 3,\n select_avatar: 4,\n select_banner: 2,\n settings_list: 7,\n show_code: 1,\n sign_up: 2,\n sign_up_review: 4,\n tweet_selection_urt: 1,\n update_users: 1,\n upload_media: 1,\n user_recommendations_list: 4,\n user_recommendations_urt: 1,\n wait_spinner: 3,\n web_modal: 1,\n },\n });\n }\n\n private async handleJsInstrumentationSubtask(\n subtaskId: string,\n _prev: TwitterUserAuthFlowResponse,\n _credentials: TwitterUserAuthCredentials,\n api: FlowSubtaskHandlerApi,\n ): Promise<FlowTokenResult> {\n return await api.sendFlowRequest({\n flow_token: api.getFlowToken(),\n subtask_inputs: [\n {\n subtask_id: subtaskId,\n js_instrumentation: {\n response: '{}',\n link: 'next_link',\n },\n },\n ],\n });\n }\n\n private async handleEnterAlternateIdentifierSubtask(\n subtaskId: string,\n _prev: TwitterUserAuthFlowResponse,\n credentials: TwitterUserAuthCredentials,\n api: FlowSubtaskHandlerApi,\n ): Promise<FlowTokenResult> {\n return await this.executeFlowTask({\n flow_token: api.getFlowToken(),\n subtask_inputs: [\n {\n subtask_id: subtaskId,\n enter_text: {\n text: credentials.email,\n link: 'next_link',\n },\n },\n ],\n });\n }\n\n private async handleEnterUserIdentifierSSO(\n subtaskId: string,\n _prev: TwitterUserAuthFlowResponse,\n credentials: TwitterUserAuthCredentials,\n api: FlowSubtaskHandlerApi,\n ): Promise<FlowTokenResult> {\n return await this.executeFlowTask({\n flow_token: api.getFlowToken(),\n subtask_inputs: [\n {\n subtask_id: subtaskId,\n settings_list: {\n setting_responses: [\n {\n key: 'user_identifier',\n response_data: {\n text_data: { result: credentials.username },\n },\n },\n ],\n link: 'next_link',\n },\n },\n ],\n });\n }\n\n private async handleEnterPassword(\n subtaskId: string,\n _prev: TwitterUserAuthFlowResponse,\n credentials: TwitterUserAuthCredentials,\n api: FlowSubtaskHandlerApi,\n ): Promise<FlowTokenResult> {\n return await this.executeFlowTask({\n flow_token: api.getFlowToken(),\n subtask_inputs: [\n {\n subtask_id: subtaskId,\n enter_password: {\n password: credentials.password,\n link: 'next_link',\n },\n },\n ],\n });\n }\n\n private async handleAccountDuplicationCheck(\n subtaskId: string,\n _prev: TwitterUserAuthFlowResponse,\n _credentials: TwitterUserAuthCredentials,\n api: FlowSubtaskHandlerApi,\n ): Promise<FlowTokenResult> {\n return await this.executeFlowTask({\n flow_token: api.getFlowToken(),\n subtask_inputs: [\n {\n subtask_id: subtaskId,\n check_logged_in_account: {\n link: 'AccountDuplicationCheck_false',\n },\n },\n ],\n });\n }\n\n private async handleTwoFactorAuthChallenge(\n subtaskId: string,\n _prev: TwitterUserAuthFlowResponse,\n credentials: TwitterUserAuthCredentials,\n api: FlowSubtaskHandlerApi,\n ): Promise<FlowTokenResult> {\n if (!credentials.twoFactorSecret) {\n return {\n status: 'error',\n err: new AuthenticationError(\n 'Two-factor authentication is required but no secret was provided',\n ),\n };\n }\n\n const totp = new OTPAuth.TOTP({ secret: credentials.twoFactorSecret });\n let error;\n for (let attempts = 1; attempts < 4; attempts += 1) {\n try {\n return await api.sendFlowRequest({\n flow_token: api.getFlowToken(),\n subtask_inputs: [\n {\n subtask_id: subtaskId,\n enter_text: {\n link: 'next_link',\n text: totp.generate(),\n },\n },\n ],\n });\n } catch (err) {\n error = err;\n await new Promise((resolve) => setTimeout(resolve, 2000 * attempts));\n }\n }\n throw error;\n }\n\n private async handleAcid(\n subtaskId: string,\n _prev: TwitterUserAuthFlowResponse,\n credentials: TwitterUserAuthCredentials,\n api: FlowSubtaskHandlerApi,\n ): Promise<FlowTokenResult> {\n return await this.executeFlowTask({\n flow_token: api.getFlowToken(),\n subtask_inputs: [\n {\n subtask_id: subtaskId,\n enter_text: {\n text: credentials.email,\n link: 'next_link',\n },\n },\n ],\n });\n }\n\n private async handleSuccessSubtask(\n _subtaskId: string,\n _prev: TwitterUserAuthFlowResponse,\n _credentials: TwitterUserAuthCredentials,\n api: FlowSubtaskHandlerApi,\n ): Promise<FlowTokenResult> {\n return await this.executeFlowTask({\n flow_token: api.getFlowToken(),\n subtask_inputs: [],\n });\n }\n\n private async executeFlowTask(\n data: TwitterUserAuthFlowRequest,\n ): Promise<FlowTokenResult> {\n let onboardingTaskUrl = 'https://api.x.com/1.1/onboarding/task.json';\n if ('flow_name' in data) {\n onboardingTaskUrl = `https://api.x.com/1.1/onboarding/task.json?flow_name=${data.flow_name}`;\n }\n\n log(`Making POST request to ${onboardingTaskUrl}`);\n\n const token = this.guestToken;\n if (token == null) {\n throw new AuthenticationError(\n 'Authentication token is null or undefined.',\n );\n }\n\n const headers = new Headers({\n authorization: `Bearer ${this.bearerToken}`,\n cookie: await this.getCookieString(),\n 'content-type': 'application/json',\n 'User-Agent':\n 'Mozilla/5.0 (Linux; Android 11; Nokia G20) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.88 Mobile Safari/537.36',\n 'x-guest-token': token,\n 'x-twitter-auth-type': 'OAuth2Client',\n 'x-twitter-active-user': 'yes',\n 'x-twitter-client-language': 'en',\n });\n await this.installCsrfToken(headers);\n\n let res: Response;\n do {\n const fetchParameters: FetchParameters = [\n onboardingTaskUrl,\n {\n credentials: 'include',\n method: 'POST',\n headers: headers,\n body: JSON.stringify(data),\n },\n ];\n\n try {\n res = await this.fetch(...fetchParameters);\n } catch (err) {\n if (!(err instanceof Error)) {\n throw err;\n }\n\n return {\n status: 'error',\n err: err,\n };\n }\n\n await updateCookieJar(this.jar, res.headers);\n\n if (res.status === 429) {\n log('Rate limit hit, waiting before retrying...');\n await this.onRateLimit({\n fetchParameters: fetchParameters,\n response: res,\n });\n }\n } while (res.status === 429);\n\n if (!res.ok) {\n return { status: 'error', err: await ApiError.fromResponse(res) };\n }\n\n const flow: TwitterUserAuthFlowResponse = await res.json();\n if (flow?.flow_token == null) {\n return {\n status: 'error',\n err: new AuthenticationError('flow_token not found.'),\n };\n }\n\n if (flow.errors?.length) {\n return {\n status: 'error',\n err: new AuthenticationError(\n `Authentication error (${flow.errors[0].code}): ${flow.errors[0].message}`,\n ),\n };\n }\n\n if (typeof flow.flow_token !== 'string') {\n return {\n status: 'error',\n err: new AuthenticationError('flow_token was not a string.'),\n };\n }\n\n const subtask = flow.subtasks?.length ? flow.subtasks[0] : undefined;\n Check(TwitterUserAuthSubtask, subtask);\n\n if (subtask && subtask.subtask_id === 'DenyLoginSubtask') {\n return {\n status: 'error',\n err: new AuthenticationError('Authentication error: DenyLoginSubtask'),\n };\n }\n\n return {\n status: 'success',\n response: flow,\n };\n }\n}\n","import stringify from 'json-stable-stringify';\n\n/**\n * Examples of requests to API endpoints. These are parsed at runtime and used\n * as templates for requests to a particular endpoint. Please ensure these do\n * not contain any information that you do not want published to NPM.\n */\nconst endpoints = {\n // TODO: Migrate other endpoint URLs here\n UserTweets:\n 'https://x.com/i/api/graphql/Li2XXGESVev94TzFtntrgA/UserTweets?variables=%7B%22userId%22%3A%221806359170830172162%22%2C%22count%22%3A20%2C%22includePromotedContent%22%3Atrue%2C%22withQuickPromoteEligibilityTweetFields%22%3Atrue%2C%22withVoice%22%3Atrue%7D&features=%7B%22rweb_video_screen_enabled%22%3Afalse%2C%22profile_label_improvements_pcf_label_in_post_enabled%22%3Atrue%2C%22rweb_tipjar_consumption_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%22premium_content_api_read_enabled%22%3Afalse%2C%22communities_web_enable_tweet_community_results_fetch%22%3Atrue%2C%22c9s_tweet_anatomy_moderator_badge_enabled%22%3Atrue%2C%22responsive_web_grok_analyze_button_fetch_trends_enabled%22%3Afalse%2C%22responsive_web_grok_analyze_post_followups_enabled%22%3Atrue%2C%22responsive_web_jetfuel_frame%22%3Afalse%2C%22responsive_web_grok_share_attachment_enabled%22%3Atrue%2C%22articles_preview_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%22responsive_web_grok_show_grok_translated_post%22%3Afalse%2C%22responsive_web_grok_analysis_button_from_backend%22%3Atrue%2C%22creator_subscriptions_quote_tweet_preview_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_grok_image_annotation_enabled%22%3Atrue%2C%22responsive_web_enhance_cards_enabled%22%3Afalse%7D&fieldToggles=%7B%22withArticlePlainText%22%3Afalse%7D',\n UserTweetsAndReplies:\n 'https://x.com/i/api/graphql/Hk4KlJ-ONjlJsucqR55P7g/UserTweetsAndReplies?variables=%7B%22userId%22%3A%221806359170830172162%22%2C%22count%22%3A20%2C%22includePromotedContent%22%3Atrue%2C%22withCommunity%22%3Atrue%2C%22withVoice%22%3Atrue%7D&features=%7B%22rweb_video_screen_enabled%22%3Afalse%2C%22profile_label_improvements_pcf_label_in_post_enabled%22%3Atrue%2C%22rweb_tipjar_consumption_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%22premium_content_api_read_enabled%22%3Afalse%2C%22communities_web_enable_tweet_community_results_fetch%22%3Atrue%2C%22c9s_tweet_anatomy_moderator_badge_enabled%22%3Atrue%2C%22responsive_web_grok_analyze_button_fetch_trends_enabled%22%3Afalse%2C%22responsive_web_grok_analyze_post_followups_enabled%22%3Atrue%2C%22responsive_web_jetfuel_frame%22%3Afalse%2C%22responsive_web_grok_share_attachment_enabled%22%3Atrue%2C%22articles_preview_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%22responsive_web_grok_show_grok_translated_post%22%3Afalse%2C%22responsive_web_grok_analysis_button_from_backend%22%3Atrue%2C%22creator_subscriptions_quote_tweet_preview_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_grok_image_annotation_enabled%22%3Atrue%2C%22responsive_web_enhance_cards_enabled%22%3Afalse%7D&fieldToggles=%7B%22withArticlePlainText%22%3Afalse%7D',\n UserLikedTweets:\n 'https://x.com/i/api/graphql/XHTMjDbiTGLQ9cP1em-aqQ/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%7D&features=%7B%22rweb_video_screen_enabled%22%3Afalse%2C%22profile_label_improvements_pcf_label_in_post_enabled%22%3Atrue%2C%22rweb_tipjar_consumption_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%22premium_content_api_read_enabled%22%3Afalse%2C%22communities_web_enable_tweet_community_results_fetch%22%3Atrue%2C%22c9s_tweet_anatomy_moderator_badge_enabled%22%3Atrue%2C%22responsive_web_grok_analyze_button_fetch_trends_enabled%22%3Afalse%2C%22responsive_web_grok_analyze_post_followups_enabled%22%3Atrue%2C%22responsive_web_jetfuel_frame%22%3Afalse%2C%22responsive_web_grok_share_attachment_enabled%22%3Atrue%2C%22articles_preview_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%22responsive_web_grok_show_grok_translated_post%22%3Afalse%2C%22responsive_web_grok_analysis_button_from_backend%22%3Atrue%2C%22creator_subscriptions_quote_tweet_preview_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_grok_image_annotation_enabled%22%3Atrue%2C%22responsive_web_enhance_cards_enabled%22%3Afalse%7D&fieldToggles=%7B%22withArticlePlainText%22%3Afalse%7D',\n UserByScreenName:\n 'https://x.com/i/api/graphql/xWw45l6nX7DP2FKRyePXSw/UserByScreenName?variables=%7B%22screen_name%22%3A%22geminiapp%22%7D&features=%7B%22hidden_profile_subscript