@the-convocation/twitter-scraper
Version:
A port of n0madic/twitter-scraper to Node.js.
1,169 lines (1,152 loc) • 92.3 kB
JavaScript
'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
})),