UNPKG

@the-convocation/twitter-scraper

Version:

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

1,169 lines (1,152 loc) 92.3 kB
'use strict'; var debug = require('debug'); var toughCookie = require('tough-cookie'); var setCookie = require('set-cookie-parser'); var headersPolyfill = require('headers-polyfill'); var fetch = require('cross-fetch'); var typebox = require('@sinclair/typebox'); var value = require('@sinclair/typebox/value'); var OTPAuth = require('otpauth'); var stringify = require('json-stable-stringify'); var tls = require('node:tls'); var node_crypto = require('node:crypto'); function _interopNamespaceDefault(e) { var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var OTPAuth__namespace = /*#__PURE__*/_interopNamespaceDefault(OTPAuth); class ApiError extends Error { constructor(response, data) { super( `Response status: ${response.status} | headers: ${JSON.stringify( headersToString(response.headers) )} | data: ${typeof data === "string" ? data : JSON.stringify(data)}` ); this.response = response; this.data = data; } static async fromResponse(response) { let data = void 0; try { if (response.headers.get("content-type")?.includes("application/json")) { data = await response.json(); } else { data = await response.text(); } } catch { try { data = await response.text(); } catch { } } return new ApiError(response, data); } } function headersToString(headers) { const result = []; headers.forEach((value, key) => { result.push(`${key}: ${value}`); }); return result.join("\n"); } class AuthenticationError extends Error { constructor(message) { super(message || "Authentication failed"); this.name = "AuthenticationError"; } } const log$3 = debug("twitter-scraper:rate-limit"); class WaitingRateLimitStrategy { async onRateLimit({ response: res }) { const xRateLimitLimit = res.headers.get("x-rate-limit-limit"); const xRateLimitRemaining = res.headers.get("x-rate-limit-remaining"); const xRateLimitReset = res.headers.get("x-rate-limit-reset"); log$3( `Rate limit event: limit=${xRateLimitLimit}, remaining=${xRateLimitRemaining}, reset=${xRateLimitReset}` ); if (xRateLimitRemaining == "0" && xRateLimitReset) { const currentTime = (/* @__PURE__ */ new Date()).valueOf() / 1e3; const timeDeltaMs = 1e3 * (parseInt(xRateLimitReset) - currentTime); await new Promise((resolve) => setTimeout(resolve, timeDeltaMs)); } } } class ErrorRateLimitStrategy { async onRateLimit({ response: res }) { throw await ApiError.fromResponse(res); } } class Platform { async randomizeCiphers() { const platform = await Platform.importPlatform(); await platform?.randomizeCiphers(); } static async importPlatform() { { const { platform } = await Promise.resolve().then(function () { return index; }); return platform; } } } async function updateCookieJar(cookieJar, headers) { const setCookieHeader = headers.get("set-cookie"); if (setCookieHeader) { const cookies = setCookie.splitCookiesString(setCookieHeader); for (const cookie of cookies.map((c) => toughCookie.Cookie.parse(c))) { if (!cookie) continue; await cookieJar.setCookie( cookie, `${cookie.secure ? "https" : "http"}://${cookie.domain}${cookie.path}` ); } } else if (typeof document !== "undefined") { for (const cookie of document.cookie.split(";")) { const hardCookie = toughCookie.Cookie.parse(cookie); if (hardCookie) { await cookieJar.setCookie(hardCookie, document.location.toString()); } } } } const log$2 = debug("twitter-scraper:api"); const bearerToken = "AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF"; async function jitter(maxMs) { const jitter2 = Math.random() * maxMs; await new Promise((resolve) => setTimeout(resolve, jitter2)); } async function requestApi(url, auth, method = "GET", platform = new Platform()) { log$2(`Making ${method} request to ${url}`); const headers = new headersPolyfill.Headers(); await auth.installTo(headers, url); await platform.randomizeCiphers(); let res; do { const fetchParameters = [ url, { method, headers, credentials: "include" } ]; try { res = await auth.fetch(...fetchParameters); } catch (err) { if (!(err instanceof Error)) { throw err; } return { success: false, err: new Error("Failed to perform request.") }; } await updateCookieJar(auth.cookieJar(), res.headers); if (res.status === 429) { log$2("Rate limit hit, waiting for retry..."); await auth.onRateLimit({ fetchParameters, response: res }); } } while (res.status === 429); if (!res.ok) { return { success: false, err: await ApiError.fromResponse(res) }; } const value = await res.json(); if (res.headers.get("x-rate-limit-incoming") == "0") { auth.deleteToken(); return { success: true, value }; } else { return { success: true, value }; } } function addApiFeatures(o) { return { ...o, 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 }; } function addApiParams(params, includeTweetReplies) { params.set("include_profile_interstitial_type", "1"); params.set("include_blocking", "1"); params.set("include_blocked_by", "1"); params.set("include_followed_by", "1"); params.set("include_want_retweets", "1"); params.set("include_mute_edge", "1"); params.set("include_can_dm", "1"); params.set("include_can_media_tag", "1"); params.set("include_ext_has_nft_avatar", "1"); params.set("include_ext_is_blue_verified", "1"); params.set("include_ext_verified_type", "1"); params.set("skip_status", "1"); params.set("cards_platform", "Web-12"); params.set("include_cards", "1"); params.set("include_ext_alt_text", "true"); params.set("include_ext_limited_action_results", "false"); params.set("include_quote_count", "true"); params.set("include_reply_count", "1"); params.set("tweet_mode", "extended"); params.set("include_ext_collab_control", "true"); params.set("include_ext_views", "true"); params.set("include_entities", "true"); params.set("include_user_entities", "true"); params.set("include_ext_media_color", "true"); params.set("include_ext_media_availability", "true"); params.set("include_ext_sensitive_media_warning", "true"); params.set("include_ext_trusted_friends_metadata", "true"); params.set("send_error_codes", "true"); params.set("simple_quoted_tweet", "true"); params.set("include_tweet_replies", `${includeTweetReplies}`); params.set( "ext", "mediaStats,highlightedLabel,hasNftAvatar,voiceInfo,birdwatchPivot,enrichments,superFollowMetadata,unmentionInfo,editControl,collab_control,vibe" ); return params; } const log$1 = debug("twitter-scraper:auth"); function withTransform(fetchFn, transform) { return async (input, init) => { const fetchArgs = await transform?.request?.(input, init) ?? [ input, init ]; const res = await fetchFn(...fetchArgs); return await transform?.response?.(res) ?? res; }; } class TwitterGuestAuth { constructor(bearerToken, options) { this.options = options; this.fetch = withTransform(options?.fetch ?? fetch, options?.transform); this.rateLimitStrategy = options?.rateLimitStrategy ?? new WaitingRateLimitStrategy(); this.bearerToken = bearerToken; this.jar = new toughCookie.CookieJar(); } async onRateLimit(event) { await this.rateLimitStrategy.onRateLimit(event); } cookieJar() { return this.jar; } isLoggedIn() { return Promise.resolve(false); } // eslint-disable-next-line @typescript-eslint/no-unused-vars login(_username, _password, _email) { return this.updateGuestToken(); } logout() { this.deleteToken(); this.jar = new toughCookie.CookieJar(); return Promise.resolve(); } deleteToken() { delete this.guestToken; delete this.guestCreatedAt; } hasToken() { return this.guestToken != null; } authenticatedAt() { if (this.guestCreatedAt == null) { return null; } return new Date(this.guestCreatedAt); } async installTo(headers) { if (this.shouldUpdate()) { await this.updateGuestToken(); } const token = this.guestToken; if (token == null) { throw new AuthenticationError( "Authentication token is null or undefined." ); } headers.set("authorization", `Bearer ${this.bearerToken}`); headers.set("x-guest-token", token); const cookies = await this.getCookies(); const xCsrfToken = cookies.find((cookie) => cookie.key === "ct0"); if (xCsrfToken) { headers.set("x-csrf-token", xCsrfToken.value); } headers.set("cookie", await this.getCookieString()); } async getCookies() { return this.jar.getCookies(this.getCookieJarUrl()); } async getCookieString() { const cookies = await this.getCookies(); return cookies.map((cookie) => `${cookie.key}=${cookie.value}`).join("; "); } async removeCookie(key) { const store = this.jar.store; const cookies = await this.jar.getCookies(this.getCookieJarUrl()); for (const cookie of cookies) { if (!cookie.domain || !cookie.path) continue; store.removeCookie(cookie.domain, cookie.path, key); if (typeof document !== "undefined") { document.cookie = `${cookie.key}=; Max-Age=0; path=${cookie.path}; domain=${cookie.domain}`; } } } getCookieJarUrl() { return typeof document !== "undefined" ? document.location.toString() : "https://x.com"; } /** * Updates the authentication state with a new guest token from the Twitter API. */ async updateGuestToken() { const guestActivateUrl = "https://api.x.com/1.1/guest/activate.json"; const headers = new headersPolyfill.Headers({ Authorization: `Bearer ${this.bearerToken}`, Cookie: await this.getCookieString() }); log$1(`Making POST request to ${guestActivateUrl}`); const res = await this.fetch(guestActivateUrl, { method: "POST", headers, referrerPolicy: "no-referrer" }); await updateCookieJar(this.jar, res.headers); if (!res.ok) { throw new AuthenticationError(await res.text()); } const o = await res.json(); if (o == null || o["guest_token"] == null) { throw new AuthenticationError("guest_token not found."); } const newGuestToken = o["guest_token"]; if (typeof newGuestToken !== "string") { throw new AuthenticationError("guest_token was not a string."); } this.guestToken = newGuestToken; this.guestCreatedAt = /* @__PURE__ */ new Date(); } /** * Returns if the authentication token needs to be updated or not. * @returns `true` if the token needs to be updated; `false` otherwise. */ shouldUpdate() { return !this.hasToken() || this.guestCreatedAt != null && this.guestCreatedAt < new Date((/* @__PURE__ */ new Date()).valueOf() - 3 * 60 * 60 * 1e3); } } const log = debug("twitter-scraper:auth-user"); const TwitterUserAuthSubtask = typebox.Type.Object({ subtask_id: typebox.Type.String(), enter_text: typebox.Type.Optional(typebox.Type.Object({})) }); class TwitterUserAuth extends TwitterGuestAuth { constructor(bearerToken, options) { super(bearerToken, options); this.subtaskHandlers = /* @__PURE__ */ new Map(); this.initializeDefaultHandlers(); } /** * Register a custom subtask handler or override an existing one * @param subtaskId The ID of the subtask to handle * @param handler The handler function that processes the subtask */ registerSubtaskHandler(subtaskId, handler) { this.subtaskHandlers.set(subtaskId, handler); } initializeDefaultHandlers() { this.subtaskHandlers.set( "LoginJsInstrumentationSubtask", this.handleJsInstrumentationSubtask.bind(this) ); this.subtaskHandlers.set( "LoginEnterUserIdentifierSSO", this.handleEnterUserIdentifierSSO.bind(this) ); this.subtaskHandlers.set( "LoginEnterAlternateIdentifierSubtask", this.handleEnterAlternateIdentifierSubtask.bind(this) ); this.subtaskHandlers.set( "LoginEnterPassword", this.handleEnterPassword.bind(this) ); this.subtaskHandlers.set( "AccountDuplicationCheck", this.handleAccountDuplicationCheck.bind(this) ); this.subtaskHandlers.set( "LoginTwoFactorAuthChallenge", this.handleTwoFactorAuthChallenge.bind(this) ); this.subtaskHandlers.set("LoginAcid", this.handleAcid.bind(this)); this.subtaskHandlers.set( "LoginSuccessSubtask", this.handleSuccessSubtask.bind(this) ); } async isLoggedIn() { const res = await requestApi( "https://api.x.com/1.1/account/verify_credentials.json", this ); if (!res.success) { return false; } const { value: verify } = res; return verify && !verify.errors?.length; } async login(username, password, email, twoFactorSecret) { await this.updateGuestToken(); const credentials = { username, password, email, twoFactorSecret }; let next = await this.initLogin(); while (next.status === "success" && next.response.subtasks?.length) { const flowToken = next.response.flow_token; if (flowToken == null) { throw new Error("flow_token not found."); } const subtaskId = next.response.subtasks[0].subtask_id; const handler = this.subtaskHandlers.get(subtaskId); if (handler) { next = await handler(subtaskId, next.response, credentials, { sendFlowRequest: this.executeFlowTask.bind(this), getFlowToken: () => flowToken }); } else { throw new Error(`Unknown subtask ${subtaskId}`); } } if (next.status === "error") { throw next.err; } } async logout() { if (!this.hasToken()) { return; } try { await requestApi( "https://api.x.com/1.1/account/logout.json", this, "POST" ); } catch (error) { console.warn("Error during logout:", error); } finally { this.deleteToken(); this.jar = new toughCookie.CookieJar(); } } async installCsrfToken(headers) { const cookies = await this.getCookies(); const xCsrfToken = cookies.find((cookie) => cookie.key === "ct0"); if (xCsrfToken) { headers.set("x-csrf-token", xCsrfToken.value); } } async installTo(headers) { headers.set("authorization", `Bearer ${this.bearerToken}`); headers.set("cookie", await this.getCookieString()); await this.installCsrfToken(headers); } async initLogin() { this.removeCookie("twitter_ads_id="); this.removeCookie("ads_prefs="); this.removeCookie("_twitter_sess="); this.removeCookie("zipbox_forms_auth_token="); this.removeCookie("lang="); this.removeCookie("bouncer_reset_cookie="); this.removeCookie("twid="); this.removeCookie("twitter_ads_idb="); this.removeCookie("email_uid="); this.removeCookie("external_referer="); this.removeCookie("ct0="); this.removeCookie("aa_u="); this.removeCookie("__cf_bm="); return await this.executeFlowTask({ flow_name: "login", input_flow_data: { flow_context: { debug_overrides: {}, start_location: { location: "unknown" } } }, subtask_versions: { action_list: 2, alert_dialog: 1, app_download_cta: 1, check_logged_in_account: 1, choice_selection: 3, contacts_live_sync_permission_prompt: 0, cta: 7, email_verification: 2, end_flow: 1, enter_date: 1, enter_email: 2, enter_password: 5, enter_phone: 2, enter_recaptcha: 1, enter_text: 5, enter_username: 2, generic_urt: 3, in_app_notification: 1, interest_picker: 3, js_instrumentation: 1, menu_dialog: 1, notifications_permission_prompt: 2, open_account: 2, open_home_timeline: 1, open_link: 1, phone_verification: 4, privacy_options: 1, security_key: 3, select_avatar: 4, select_banner: 2, settings_list: 7, show_code: 1, sign_up: 2, sign_up_review: 4, tweet_selection_urt: 1, update_users: 1, upload_media: 1, user_recommendations_list: 4, user_recommendations_urt: 1, wait_spinner: 3, web_modal: 1 } }); } async handleJsInstrumentationSubtask(subtaskId, _prev, _credentials, api) { return await api.sendFlowRequest({ flow_token: api.getFlowToken(), subtask_inputs: [ { subtask_id: subtaskId, js_instrumentation: { response: "{}", link: "next_link" } } ] }); } async handleEnterAlternateIdentifierSubtask(subtaskId, _prev, credentials, api) { return await this.executeFlowTask({ flow_token: api.getFlowToken(), subtask_inputs: [ { subtask_id: subtaskId, enter_text: { text: credentials.email, link: "next_link" } } ] }); } async handleEnterUserIdentifierSSO(subtaskId, _prev, credentials, api) { return await this.executeFlowTask({ flow_token: api.getFlowToken(), subtask_inputs: [ { subtask_id: subtaskId, settings_list: { setting_responses: [ { key: "user_identifier", response_data: { text_data: { result: credentials.username } } } ], link: "next_link" } } ] }); } async handleEnterPassword(subtaskId, _prev, credentials, api) { return await this.executeFlowTask({ flow_token: api.getFlowToken(), subtask_inputs: [ { subtask_id: subtaskId, enter_password: { password: credentials.password, link: "next_link" } } ] }); } async handleAccountDuplicationCheck(subtaskId, _prev, _credentials, api) { return await this.executeFlowTask({ flow_token: api.getFlowToken(), subtask_inputs: [ { subtask_id: subtaskId, check_logged_in_account: { link: "AccountDuplicationCheck_false" } } ] }); } async handleTwoFactorAuthChallenge(subtaskId, _prev, credentials, api) { if (!credentials.twoFactorSecret) { return { status: "error", err: new AuthenticationError( "Two-factor authentication is required but no secret was provided" ) }; } const totp = new OTPAuth__namespace.TOTP({ secret: credentials.twoFactorSecret }); let error; for (let attempts = 1; attempts < 4; attempts += 1) { try { return await api.sendFlowRequest({ flow_token: api.getFlowToken(), subtask_inputs: [ { subtask_id: subtaskId, enter_text: { link: "next_link", text: totp.generate() } } ] }); } catch (err) { error = err; await new Promise((resolve) => setTimeout(resolve, 2e3 * attempts)); } } throw error; } async handleAcid(subtaskId, _prev, credentials, api) { return await this.executeFlowTask({ flow_token: api.getFlowToken(), subtask_inputs: [ { subtask_id: subtaskId, enter_text: { text: credentials.email, link: "next_link" } } ] }); } async handleSuccessSubtask(_subtaskId, _prev, _credentials, api) { return await this.executeFlowTask({ flow_token: api.getFlowToken(), subtask_inputs: [] }); } async executeFlowTask(data) { let onboardingTaskUrl = "https://api.x.com/1.1/onboarding/task.json"; if ("flow_name" in data) { onboardingTaskUrl = `https://api.x.com/1.1/onboarding/task.json?flow_name=${data.flow_name}`; } log(`Making POST request to ${onboardingTaskUrl}`); const token = this.guestToken; if (token == null) { throw new AuthenticationError( "Authentication token is null or undefined." ); } const headers = new headersPolyfill.Headers({ authorization: `Bearer ${this.bearerToken}`, cookie: await this.getCookieString(), "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": token, "x-twitter-auth-type": "OAuth2Client", "x-twitter-active-user": "yes", "x-twitter-client-language": "en" }); await this.installCsrfToken(headers); let res; do { const fetchParameters = [ onboardingTaskUrl, { credentials: "include", method: "POST", headers, body: JSON.stringify(data) } ]; try { res = await this.fetch(...fetchParameters); } catch (err) { if (!(err instanceof Error)) { throw err; } return { status: "error", err }; } await updateCookieJar(this.jar, res.headers); if (res.status === 429) { log("Rate limit hit, waiting before retrying..."); await this.onRateLimit({ fetchParameters, response: res }); } } while (res.status === 429); if (!res.ok) { return { status: "error", err: await ApiError.fromResponse(res) }; } const flow = await res.json(); if (flow?.flow_token == null) { return { status: "error", err: new AuthenticationError("flow_token not found.") }; } if (flow.errors?.length) { return { status: "error", err: new AuthenticationError( `Authentication error (${flow.errors[0].code}): ${flow.errors[0].message}` ) }; } if (typeof flow.flow_token !== "string") { return { status: "error", err: new AuthenticationError("flow_token was not a string.") }; } const subtask = flow.subtasks?.length ? flow.subtasks[0] : void 0; value.Check(TwitterUserAuthSubtask, subtask); if (subtask && subtask.subtask_id === "DenyLoginSubtask") { return { status: "error", err: new AuthenticationError("Authentication error: DenyLoginSubtask") }; } return { status: "success", response: flow }; } } const endpoints = { // TODO: Migrate other endpoint URLs here UserTweets: "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", UserTweetsAndReplies: "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", UserLikedTweets: "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", UserByScreenName: "https://x.com/i/api/graphql/xWw45l6nX7DP2FKRyePXSw/UserByScreenName?variables=%7B%22screen_name%22%3A%22geminiapp%22%7D&features=%7B%22hidden_profile_subscriptions_enabled%22%3Atrue%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%22subscriptions_verification_info_is_identity_verified_enabled%22%3Atrue%2C%22subscriptions_verification_info_verified_since_enabled%22%3Atrue%2C%22highlights_tweets_tab_ui_enabled%22%3Atrue%2C%22responsive_web_twitter_article_notes_tab_enabled%22%3Atrue%2C%22subscriptions_feature_can_gift_premium%22%3Atrue%2C%22creator_subscriptions_tweet_preview_api_enabled%22%3Atrue%2C%22responsive_web_graphql_skip_user_profile_image_extensions_enabled%22%3Afalse%2C%22responsive_web_graphql_timeline_navigation_enabled%22%3Atrue%7D&fieldToggles=%7B%22withAuxiliaryUserLabels%22%3Atrue%7D", TweetDetail: "https://x.com/i/api/graphql/u5Tij6ERlSH2LZvCUqallw/TweetDetail?variables=%7B%22focalTweetId%22%3A%221924893675529900467%22%2C%22referrer%22%3A%22profile%22%2C%22with_rux_injections%22%3Afalse%2C%22rankingMode%22%3A%22Relevance%22%2C%22includePromotedContent%22%3Atrue%2C%22withCommunity%22%3Atrue%2C%22withQuickPromoteEligibilityTweetFields%22%3Atrue%2C%22withBirdwatchNotes%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%22withArticleRichContentState%22%3Atrue%2C%22withArticlePlainText%22%3Afalse%2C%22withGrokAnalyze%22%3Afalse%2C%22withDisallowedReplyControls%22%3Afalse%7D", TweetResultByRestId: "https://api.x.com/graphql/Opujkru5iJSDWj4DuJISOw/TweetResultByRestId?variables=%7B%22tweetId%22%3A%221924893675529900467%22%2C%22withCommunity%22%3Afalse%2C%22includePromotedContent%22%3Afalse%2C%22withVoice%22%3Afalse%7D&features=%7B%22creator_subscriptions_tweet_preview_api_enabled%22%3Atrue%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%3Afalse%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%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%22responsive_web_grok_image_annotation_enabled%22%3Atrue%2C%22responsive_web_graphql_skip_user_profile_image_extensions_enabled%22%3Afalse%2C%22responsive_web_graphql_timeline_navigation_enabled%22%3Atrue%2C%22responsive_web_enhance_cards_enabled%22%3Afalse%7D&fieldToggles=%7B%22withArticleRichContentState%22%3Atrue%2C%22withArticlePlainText%22%3Afalse%2C%22withGrokAnalyze%22%3Afalse%2C%22withDisallowedReplyControls%22%3Afalse%7D", ListTweets: "https://x.com/i/api/graphql/S1Sm3_mNJwa-fnY9htcaAQ/ListLatestTweetsTimeline?variables=%7B%22listId%22%3A%221736495155002106192%22%2C%22count%22%3A20%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" }; class ApiRequest { constructor(info) { this.url = info.url; this.variables = info.variables; this.features = info.features; this.fieldToggles = info.fieldToggles; } toRequestUrl() { const params = new URLSearchParams(); if (this.variables) { params.set("variables", stringify(this.variables)); } if (this.features) { params.set("features", stringify(this.features)); } if (this.fieldToggles) { params.set("fieldToggles", stringify(this.fieldToggles)); } return `${this.url}?${params.toString()}`; } } function parseEndpointExample(example) { const { protocol, host, pathname, searchParams: query } = new URL(example); const base = `${protocol}//${host}${pathname}`; const variables = query.get("variables"); const features = query.get("features"); const fieldToggles = query.get("fieldToggles"); return new ApiRequest({ url: base, variables: variables ? JSON.parse(variables) : void 0, features: features ? JSON.parse(features) : void 0, fieldToggles: fieldToggles ? JSON.parse(fieldToggles) : void 0 }); } function createApiRequestFactory(endpoints2) { return Object.entries(endpoints2).map(([endpointName, endpointExample]) => { return { [`create${endpointName}Request`]: () => { return parseEndpointExample(endpointExample); } }; }).reduce((agg, next) => { return Object.assign(agg, next); }); } const apiRequestFactory = createApiRequestFactory(endpoints); function getAvatarOriginalSizeUrl(avatarUrl) { return avatarUrl ? avatarUrl.replace("_normal", "") : void 0; } function parseProfile(legacy, isBlueVerified) { const profile = { avatar: getAvatarOriginalSizeUrl(legacy.profile_image_url_https), banner: legacy.profile_banner_url, biography: legacy.description, followersCount: legacy.followers_count, followingCount: legacy.friends_count, friendsCount: legacy.friends_count, mediaCount: legacy.media_count, isPrivate: legacy.protected ?? false, isVerified: legacy.verified, likesCount: legacy.favourites_count, listedCount: legacy.listed_count, location: legacy.location, name: legacy.name, pinnedTweetIds: legacy.pinned_tweet_ids_str, tweetsCount: legacy.statuses_count, url: `https://x.com/${legacy.screen_name}`, userId: legacy.id_str, username: legacy.screen_name, isBlueVerified: isBlueVerified ?? false, canDm: legacy.can_dm }; if (legacy.created_at != null) { profile.joined = new Date(Date.parse(legacy.created_at)); } const urls = legacy.entities?.url?.urls; if (urls?.length != null && urls?.length > 0) { profile.website = urls[0].expanded_url; } return profile; } async function getProfile(username, auth) { const request = apiRequestFactory.createUserByScreenNameRequest(); request.variables.screen_name = username; request.variables.withSafetyModeUserFields = true; request.features.hidden_profile_subscriptions_enabled = false; request.fieldToggles.withAuxiliaryUserLabels = false; const res = await requestApi(request.toRequestUrl(), auth); if (!res.success) { return res; } const { value } = res; const { errors } = value; if (errors != null && errors.length > 0) { return { success: false, err: new Error(errors[0].message) }; } if (!value.data || !value.data.user || !value.data.user.result) { return { success: false, err: new Error("User not found.") }; } const { result: user } = value.data.user; const { legacy } = user; if (user.rest_id == null || user.rest_id.length === 0) { return { success: false, err: new Error("rest_id not found.") }; } legacy.id_str = user.rest_id; legacy.screen_name ?? (legacy.screen_name = user.core?.screen_name); legacy.profile_image_url_https ?? (legacy.profile_image_url_https = user.avatar?.image_url); legacy.created_at ?? (legacy.created_at = user.core?.created_at); legacy.location ?? (legacy.location = user.location?.location); legacy.name ?? (legacy.name = user.core?.name); if (legacy.screen_name == null || legacy.screen_name.length === 0) { return { success: false, err: new Error(`User ${username} does not exist or is private.`) }; } return { success: true, value: parseProfile(legacy, user.is_blue_verified) }; } const idCache = /* @__PURE__ */ new Map(); async function getUserIdByScreenName(screenName, auth) { const cached = idCache.get(screenName); if (cached != null) { return { success: true, value: cached }; } const profileRes = await getProfile(screenName, auth); if (!profileRes.success) { return profileRes; } const profile = profileRes.value; if (profile.userId != null) { idCache.set(screenName, profile.userId); return { success: true, value: profile.userId }; } return { success: false, err: new Error("User ID is undefined.") }; } async function* getUserTimeline(query, maxProfiles, fetchFunc) { let nProfiles = 0; let cursor = void 0; let consecutiveEmptyBatches = 0; while (nProfiles < maxProfiles) { const batch = await fetchFunc( query, maxProfiles, cursor ); const { profiles, next } = batch; cursor = next; if (profiles.length === 0) { consecutiveEmptyBatches++; if (consecutiveEmptyBatches > 5) break; } else consecutiveEmptyBatches = 0; for (const profile of profiles) { if (nProfiles < maxProfiles) yield profile; else break; nProfiles++; } if (!next) break; await jitter(1e3); } } async function* getTweetTimeline(query, maxTweets, fetchFunc) { let nTweets = 0; let cursor = void 0; while (nTweets < maxTweets) { const batch = await fetchFunc( query, maxTweets, cursor ); const { tweets, next } = batch; if (tweets.length === 0) { break; } for (const tweet of tweets) { if (nTweets < maxTweets) { cursor = next; yield tweet; } else { break; } nTweets++; } await jitter(1e3); } } function isFieldDefined(key) { return function(value) { return isDefined(value[key]); }; } function isDefined(value) { return value != null; } const reHashtag = /\B(\#\S+\b)/g; const reCashtag = /\B(\$\S+\b)/g; const reTwitterUrl = /https:(\/\/t\.co\/([A-Za-z0-9]|[A-Za-z]){10})/g; const reUsername = /\B(\@\S{1,15}\b)/g; function parseMediaGroups(media) { const photos = []; const videos = []; let sensitiveContent = void 0; for (const m of media.filter(isFieldDefined("id_str")).filter(isFieldDefined("media_url_https"))) { if (m.type === "photo") { photos.push({ id: m.id_str, url: m.media_url_https, alt_text: m.ext_alt_text }); } else if (m.type === "video" || m.type === "animated_gif") { videos.push(parseVideo(m)); } const sensitive = m.ext_sensitive_media_warning; if (sensitive != null) { sensitiveContent = sensitive.adult_content || sensitive.graphic_violence || sensitive.other; } } return { sensitiveContent, photos, videos }; } function parseVideo(m) { const video = { id: m.id_str, preview: m.media_url_https }; let maxBitrate = 0; const variants = m.video_info?.variants ?? []; for (const variant of variants) { const bitrate = variant.bitrate; if (bitrate != null && bitrate > maxBitrate && variant.url != null) { let variantUrl = variant.url; const stringStart = 0; const tagSuffixIdx = variantUrl.indexOf("?tag=10"); if (tagSuffixIdx !== -1) { variantUrl = variantUrl.substring(stringStart, tagSuffixIdx + 1); } video.url = variantUrl; maxBitrate = bitrate; } } return video; } function reconstructTweetHtml(tweet, photos, videos) { const media = []; let html = tweet.full_text ?? ""; html = html.replace(reHashtag, linkHashtagHtml); html = html.replace(reCashtag, linkCashtagHtml); html = html.replace(reUsername, linkUsernameHtml); html = html.replace(reTwitterUrl, unwrapTcoUrlHtml(tweet, media)); for (const { url } of photos) { if (media.indexOf(url) !== -1) { continue; } html += `<br><img src="${url}"/>`; } for (const { preview: url } of videos) { if (media.indexOf(url) !== -1) { continue; } html += `<br><img src="${url}"/>`; } html = html.replace(/\n/g, "<br>"); return html; } function linkHashtagHtml(hashtag) { return `<a href="https://x.com/hashtag/${hashtag.replace( "#", "" )}">${hashtag}</a>`; } function linkCashtagHtml(cashtag) { return `<a href="https://x.com/search?q=%24${cashtag.replace( "$", "" )}">${cashtag}</a>`; } function linkUsernameHtml(username) { return `<a href="https://x.com/${username.replace("@", "")}">${username}</a>`; } function unwrapTcoUrlHtml(tweet, foundedMedia) { return function(tco) { for (const entity of tweet.entities?.urls ?? []) { if (tco === entity.url && entity.expanded_url != null) { return `<a href="${entity.expanded_url}">${tco}</a>`; } } for (const entity of tweet.extended_entities?.media ?? []) { if (tco === entity.url && entity.media_url_https != null) { foundedMedia.push(entity.media_url_https); return `<br><a href="${tco}"><img src="${entity.media_url_https}"/></a>`; } } return tco; }; } function getLegacyTweetId(tweet) { if (tweet.id_str) { return tweet.id_str; } return tweet.conversation_id_str; } function parseLegacyTweet(coreUser, user, tweet, editControl) { if (tweet == null) { return { success: false, err: new Error("Tweet was not found in the timeline object.") }; } if (user == null) { return { success: false, err: new Error("User was not found in the timeline object.") }; } const tweetId = getLegacyTweetId(tweet); if (!tweetId) { return { success: false, err: new Error("Tweet ID was not found in object.") }; } const hashtags = tweet.entities?.hashtags ?? []; const mentions = tweet.entities?.user_mentions ?? []; const media = tweet.extended_entities?.media ?? []; const pinnedTweets = new Set( user.pinned_tweet_ids_str ?? [] ); const urls = tweet.entities?.urls ?? []; const { photos, videos, sensitiveContent } = parseMediaGroups(media); const tweetVersions = editControl?.edit_tweet_ids ?? [tweetId]; const name = user.name ?? coreUser?.name; const username = user.screen_name ?? coreUser?.screen_name; const tw = { __raw_UNSTABLE: tweet, bookmarkCount: tweet.bookmark_count, conversationId: tweet.conversation_id_str, id: tweetId, hashtags: hashtags.filter(isFieldDefined("text")).map((hashtag) => hashtag.text), likes: tweet.favorite_count, mentions: mentions.filter(isFieldDefined("id_str")).map((mention) => ({ id: mention.id_str, username: mention.screen_name, name: mention.name })),