From 05360060123960adf2394068e409071e4a2e2538 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 12 Jan 2026 02:09:37 +0000 Subject: [PATCH] refactor: extract user lookup mixin --- src/lib/twitter-client-user-lookup.ts | 211 ++++++++++++++++++++++++++ src/lib/twitter-client-user-tweets.ts | 197 ------------------------ src/lib/twitter-client.ts | 10 +- 3 files changed, 218 insertions(+), 200 deletions(-) create mode 100644 src/lib/twitter-client-user-lookup.ts diff --git a/src/lib/twitter-client-user-lookup.ts b/src/lib/twitter-client-user-lookup.ts new file mode 100644 index 0000000..b4d6061 --- /dev/null +++ b/src/lib/twitter-client-user-lookup.ts @@ -0,0 +1,211 @@ +import { normalizeHandle } from './normalize-handle.js'; +import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js'; +import { TWITTER_API_BASE } from './twitter-client-constants.js'; + +/** Result of username to userId lookup */ +export interface UserLookupResult { + success: boolean; + userId?: string; + username?: string; + name?: string; + error?: string; +} + +export interface TwitterClientUserLookupMethods { + getUserIdByUsername(username: string): Promise; +} + +export function withUserLookup>( + Base: TBase, +): Mixin { + abstract class TwitterClientUserLookup extends Base { + // biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement. + constructor(...args: any[]) { + super(...args); + } + + private async getUserByScreenNameGraphQL(screenName: string): Promise { + // UserByScreenName query IDs observed from web client + const queryIds = ['xc8f1g7BYqr6VTzTbvNlGw', 'qW5u-DAuXpMEG0zA1F7UGQ', 'sLVLhk0bGj3MVFEKTdax1w']; + + const variables = { + screen_name: screenName, + withSafetyModeUserFields: true, + }; + + const features = { + hidden_profile_subscriptions_enabled: true, + hidden_profile_likes_enabled: true, + rweb_tipjar_consumption_enabled: true, + responsive_web_graphql_exclude_directive_enabled: true, + verified_phone_label_enabled: false, + subscriptions_verification_info_is_identity_verified_enabled: true, + subscriptions_verification_info_verified_since_enabled: true, + highlights_tweets_tab_ui_enabled: true, + responsive_web_twitter_article_notes_tab_enabled: true, + subscriptions_feature_can_gift_premium: true, + creator_subscriptions_tweet_preview_api_enabled: true, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + responsive_web_graphql_timeline_navigation_enabled: true, + blue_business_profile_image_shape_enabled: true, + }; + + const fieldToggles = { + withAuxiliaryUserLabels: false, + }; + + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(features), + fieldToggles: JSON.stringify(fieldToggles), + }); + + let lastError: string | undefined; + + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/UserByScreenName?${params.toString()}`; + + try { + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (!response.ok) { + const text = await response.text(); + if (response.status === 404) { + // Try next query ID + lastError = `HTTP ${response.status}`; + continue; + } + lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`; + continue; + } + + const data = (await response.json()) as { + data?: { + user?: { + result?: { + __typename?: string; + rest_id?: string; + legacy?: { + screen_name?: string; + name?: string; + }; + core?: { + screen_name?: string; + name?: string; + }; + }; + }; + }; + errors?: Array<{ message: string }>; + }; + + // Check for user not found + if (data.data?.user?.result?.__typename === 'UserUnavailable') { + return { success: false, error: `User @${screenName} not found or unavailable` }; + } + + const userResult = data.data?.user?.result; + const userId = userResult?.rest_id; + const username = userResult?.legacy?.screen_name ?? userResult?.core?.screen_name; + const name = userResult?.legacy?.name ?? userResult?.core?.name; + + if (userId && username) { + return { + success: true, + userId, + username, + name, + }; + } + + if (data.errors && data.errors.length > 0) { + lastError = data.errors.map((e) => e.message).join(', '); + continue; + } + + lastError = 'Could not parse user data from response'; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { success: false, error: lastError ?? 'Unknown error looking up user' }; + } + + /** + * Look up a user's ID by their username/handle. + * Uses GraphQL UserByScreenName first, then falls back to REST on transient failures. + */ + async getUserIdByUsername(username: string): Promise { + const cleanUsername = normalizeHandle(username); + if (!cleanUsername) { + return { success: false, error: `Invalid username: ${username}` }; + } + + const graphqlResult = await this.getUserByScreenNameGraphQL(cleanUsername); + if (graphqlResult.success) { + return graphqlResult; + } + + // If GraphQL definitively says user is unavailable, don't retry with REST + if (graphqlResult.error?.includes('not found or unavailable')) { + return graphqlResult; + } + + // Fallback to REST API for transient GraphQL errors + const urls = [ + `https://x.com/i/api/1.1/users/show.json?screen_name=${encodeURIComponent(cleanUsername)}`, + `https://api.twitter.com/1.1/users/show.json?screen_name=${encodeURIComponent(cleanUsername)}`, + ]; + + let lastError: string | undefined = graphqlResult.error; + + for (const url of urls) { + try { + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (!response.ok) { + const text = await response.text(); + if (response.status === 404) { + return { success: false, error: `User @${cleanUsername} not found` }; + } + lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`; + continue; + } + + const data = (await response.json()) as { + id_str?: string; + id?: number; + screen_name?: string; + name?: string; + }; + + const userId = data.id_str ?? (data.id ? String(data.id) : null); + if (!userId) { + lastError = 'Could not parse user ID from response'; + continue; + } + + return { + success: true, + userId, + username: data.screen_name ?? cleanUsername, + name: data.name, + }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { success: false, error: lastError ?? 'Unknown error looking up user' }; + } + } + + return TwitterClientUserLookup; +} diff --git a/src/lib/twitter-client-user-tweets.ts b/src/lib/twitter-client-user-tweets.ts index 453d5b4..42b2bcc 100644 --- a/src/lib/twitter-client-user-tweets.ts +++ b/src/lib/twitter-client-user-tweets.ts @@ -1,4 +1,3 @@ -import { normalizeHandle } from './normalize-handle.js'; import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js'; import { TWITTER_API_BASE } from './twitter-client-constants.js'; import { buildUserTweetsFeatures } from './twitter-client-features.js'; @@ -21,17 +20,7 @@ export interface UserTweetsPaginationOptions extends UserTweetsFetchOptions { pageDelayMs?: number; } -/** Result of username to userId lookup */ -export interface UserLookupResult { - success: boolean; - userId?: string; - username?: string; - name?: string; - error?: string; -} - export interface TwitterClientUserTweetsMethods { - getUserIdByUsername(username: string): Promise; getUserTweets(userId: string, count?: number, options?: UserTweetsFetchOptions): Promise; getUserTweetsPaged(userId: string, limit: number, options?: UserTweetsPaginationOptions): Promise; } @@ -51,196 +40,10 @@ export function withUserTweets { - // UserByScreenName query IDs observed from web client - const queryIds = ['xc8f1g7BYqr6VTzTbvNlGw', 'qW5u-DAuXpMEG0zA1F7UGQ', 'sLVLhk0bGj3MVFEKTdax1w']; - - const variables = { - screen_name: screenName, - withSafetyModeUserFields: true, - }; - - const features = { - hidden_profile_subscriptions_enabled: true, - hidden_profile_likes_enabled: true, - rweb_tipjar_consumption_enabled: true, - responsive_web_graphql_exclude_directive_enabled: true, - verified_phone_label_enabled: false, - subscriptions_verification_info_is_identity_verified_enabled: true, - subscriptions_verification_info_verified_since_enabled: true, - highlights_tweets_tab_ui_enabled: true, - responsive_web_twitter_article_notes_tab_enabled: true, - subscriptions_feature_can_gift_premium: true, - creator_subscriptions_tweet_preview_api_enabled: true, - responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, - responsive_web_graphql_timeline_navigation_enabled: true, - blue_business_profile_image_shape_enabled: true, - }; - - const fieldToggles = { - withAuxiliaryUserLabels: false, - }; - - const params = new URLSearchParams({ - variables: JSON.stringify(variables), - features: JSON.stringify(features), - fieldToggles: JSON.stringify(fieldToggles), - }); - - let lastError: string | undefined; - - for (const queryId of queryIds) { - const url = `${TWITTER_API_BASE}/${queryId}/UserByScreenName?${params.toString()}`; - - try { - const response = await this.fetchWithTimeout(url, { - method: 'GET', - headers: this.getHeaders(), - }); - - if (!response.ok) { - const text = await response.text(); - if (response.status === 404) { - // Try next query ID - lastError = `HTTP ${response.status}`; - continue; - } - lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`; - continue; - } - - const data = (await response.json()) as { - data?: { - user?: { - result?: { - __typename?: string; - rest_id?: string; - id?: string; - legacy?: { - screen_name?: string; - name?: string; - }; - core?: { - screen_name?: string; - name?: string; - }; - }; - }; - }; - errors?: Array<{ message: string }>; - }; - - // Check for user not found - if (data.data?.user?.result?.__typename === 'UserUnavailable') { - return { success: false, error: `User @${screenName} not found or unavailable` }; - } - - const userResult = data.data?.user?.result; - const userId = userResult?.rest_id; - const username = userResult?.legacy?.screen_name ?? userResult?.core?.screen_name; - const name = userResult?.legacy?.name ?? userResult?.core?.name; - - if (userId && username) { - return { - success: true, - userId, - username, - name, - }; - } - - if (data.errors && data.errors.length > 0) { - lastError = data.errors.map((e) => e.message).join(', '); - continue; - } - - lastError = 'Could not parse user data from response'; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - } - } - - return { success: false, error: lastError ?? 'Unknown error looking up user' }; - } - private async sleep(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } - /** - * Look up a user's ID by their username/handle. - * Uses GraphQL UserByScreenName first, then falls back to REST on transient failures. - */ - async getUserIdByUsername(username: string): Promise { - // Normalize and validate handle - const cleanUsername = normalizeHandle(username); - if (!cleanUsername) { - return { success: false, error: `Invalid username: ${username}` }; - } - - // Try GraphQL UserByScreenName first (more reliable with cookie auth) - const graphqlResult = await this.getUserByScreenNameGraphQL(cleanUsername); - if (graphqlResult.success) { - return graphqlResult; - } - - // If GraphQL definitively says user is unavailable, don't retry with REST - if (graphqlResult.error?.includes('not found or unavailable')) { - return graphqlResult; - } - - // Fallback to REST API for transient GraphQL errors - const urls = [ - `https://x.com/i/api/1.1/users/show.json?screen_name=${encodeURIComponent(cleanUsername)}`, - `https://api.twitter.com/1.1/users/show.json?screen_name=${encodeURIComponent(cleanUsername)}`, - ]; - - let lastError: string | undefined = graphqlResult.error; - - for (const url of urls) { - try { - const response = await this.fetchWithTimeout(url, { - method: 'GET', - headers: this.getHeaders(), - }); - - if (!response.ok) { - const text = await response.text(); - // Check for user not found - if (response.status === 404) { - return { success: false, error: `User @${cleanUsername} not found` }; - } - lastError = `HTTP ${response.status}: ${text.slice(0, 200)}`; - continue; - } - - const data = (await response.json()) as { - id_str?: string; - id?: number; - screen_name?: string; - name?: string; - }; - - const userId = data.id_str ?? (data.id ? String(data.id) : null); - if (!userId) { - lastError = 'Could not parse user ID from response'; - continue; - } - - return { - success: true, - userId, - username: data.screen_name ?? cleanUsername, - name: data.name, - }; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - } - } - - return { success: false, error: lastError ?? 'Unknown error looking up user' }; - } - /** * Get tweets from a user's profile timeline (single page). */ diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index f4c5838..4924097 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -8,6 +8,7 @@ import { type TwitterClientPostingMethods, withPosting } from './twitter-client- import { type TwitterClientSearchMethods, withSearch } from './twitter-client-search.js'; import { type TwitterClientTimelineMethods, withTimelines } from './twitter-client-timelines.js'; import { type TwitterClientTweetDetailMethods, withTweetDetails } from './twitter-client-tweet-detail.js'; +import { type TwitterClientUserLookupMethods, withUserLookup } from './twitter-client-user-lookup.js'; import { type TwitterClientUserTweetsMethods, withUserTweets } from './twitter-client-user-tweets.js'; import { type TwitterClientUserMethods, withUsers } from './twitter-client-users.js'; @@ -21,12 +22,15 @@ type TwitterClientInstance = TwitterClientBase & TwitterClientTimelineMethods & TwitterClientTweetDetailMethods & TwitterClientUserMethods & + TwitterClientUserLookupMethods & TwitterClientUserTweetsMethods; const MixedTwitterClient = withUserTweets( - withUsers( - withLists( - withHome(withTimelines(withSearch(withTweetDetails(withPosting(withBookmarks(withMedia(TwitterClientBase))))))), + withUserLookup( + withUsers( + withLists( + withHome(withTimelines(withSearch(withTweetDetails(withPosting(withBookmarks(withMedia(TwitterClientBase))))))), + ), ), ), ) as AbstractConstructor;