Merge pull request #45 from steipete/refactor/user-tweets-cleanup
refactor: user lookup + paging output cleanup
This commit is contained in:
@@ -51,6 +51,17 @@ export type CliContext = {
|
|||||||
resolveCredentialsFromOptions: (opts: CredentialsOptions) => ReturnType<typeof resolveCredentials>;
|
resolveCredentialsFromOptions: (opts: CredentialsOptions) => ReturnType<typeof resolveCredentials>;
|
||||||
loadMedia: (opts: { media: string[]; alts: string[] }) => MediaSpec[];
|
loadMedia: (opts: { media: string[]; alts: string[] }) => MediaSpec[];
|
||||||
printTweets: (tweets: TweetData[], opts?: { json?: boolean; emptyMessage?: string; showSeparator?: boolean }) => void;
|
printTweets: (tweets: TweetData[], opts?: { json?: boolean; emptyMessage?: string; showSeparator?: boolean }) => void;
|
||||||
|
printTweetsResult: (
|
||||||
|
result: {
|
||||||
|
tweets?: TweetData[];
|
||||||
|
nextCursor?: string;
|
||||||
|
},
|
||||||
|
opts: {
|
||||||
|
json: boolean;
|
||||||
|
usePagination: boolean;
|
||||||
|
emptyMessage: string;
|
||||||
|
},
|
||||||
|
) => void;
|
||||||
extractTweetId: (tweetIdOrUrl: string) => string;
|
extractTweetId: (tweetIdOrUrl: string) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -389,6 +400,18 @@ export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function printTweetsResult(
|
||||||
|
result: { tweets?: TweetData[]; nextCursor?: string },
|
||||||
|
opts: { json: boolean; usePagination: boolean; emptyMessage: string },
|
||||||
|
) {
|
||||||
|
const tweets = result.tweets ?? [];
|
||||||
|
if (opts.json && opts.usePagination) {
|
||||||
|
console.log(JSON.stringify({ tweets, nextCursor: result.nextCursor ?? null }, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printTweets(tweets, { json: opts.json, emptyMessage: opts.emptyMessage });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isTty,
|
isTty,
|
||||||
getOutput: () => output,
|
getOutput: () => output,
|
||||||
@@ -402,6 +425,7 @@ export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEn
|
|||||||
resolveCredentialsFromOptions,
|
resolveCredentialsFromOptions,
|
||||||
loadMedia,
|
loadMedia,
|
||||||
printTweets,
|
printTweets,
|
||||||
|
printTweetsResult,
|
||||||
extractTweetId,
|
extractTweetId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,11 +74,7 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
|
|||||||
if (result.success && result.tweets) {
|
if (result.success && result.tweets) {
|
||||||
const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
|
const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
|
||||||
const isJson = cmdOpts.json || cmdOpts.jsonFull;
|
const isJson = cmdOpts.json || cmdOpts.jsonFull;
|
||||||
if (isJson && usePagination) {
|
ctx.printTweetsResult(result, { json: isJson, usePagination, emptyMessage });
|
||||||
console.log(JSON.stringify({ tweets: result.tweets, nextCursor: result.nextCursor ?? null }, null, 2));
|
|
||||||
} else {
|
|
||||||
ctx.printTweets(result.tweets, { json: isJson, emptyMessage });
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
console.error(`${ctx.p('err')}Failed to fetch bookmarks: ${result.error}`);
|
console.error(`${ctx.p('err')}Failed to fetch bookmarks: ${result.error}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -139,11 +139,11 @@ export function registerListsCommand(program: Command, ctx: CliContext): void {
|
|||||||
|
|
||||||
if (result.success && result.tweets) {
|
if (result.success && result.tweets) {
|
||||||
const isJson = cmdOpts.json || cmdOpts.jsonFull;
|
const isJson = cmdOpts.json || cmdOpts.jsonFull;
|
||||||
if (isJson && usePagination) {
|
ctx.printTweetsResult(result, {
|
||||||
console.log(JSON.stringify({ tweets: result.tweets, nextCursor: result.nextCursor ?? null }, null, 2));
|
json: isJson,
|
||||||
} else {
|
usePagination,
|
||||||
ctx.printTweets(result.tweets, { json: isJson, emptyMessage: 'No tweets found in this list.' });
|
emptyMessage: 'No tweets found in this list.',
|
||||||
}
|
});
|
||||||
} else {
|
} else {
|
||||||
console.error(`${ctx.p('err')}Failed to fetch list timeline: ${result.error}`);
|
console.error(`${ctx.p('err')}Failed to fetch list timeline: ${result.error}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -115,15 +115,11 @@ export function registerUserTweetsCommand(program: Command, ctx: CliContext): vo
|
|||||||
|
|
||||||
if (result.success && result.tweets) {
|
if (result.success && result.tweets) {
|
||||||
const isJson = cmdOpts.json || cmdOpts.jsonFull;
|
const isJson = cmdOpts.json || cmdOpts.jsonFull;
|
||||||
if (isJson && wantsPaginationOutput) {
|
ctx.printTweetsResult(result, {
|
||||||
// Include nextCursor for pagination consumers
|
json: isJson,
|
||||||
console.log(JSON.stringify({ tweets: result.tweets, nextCursor: result.nextCursor ?? null }, null, 2));
|
usePagination: wantsPaginationOutput,
|
||||||
} else {
|
emptyMessage: `No tweets found for @${username}.`,
|
||||||
ctx.printTweets(result.tweets, {
|
});
|
||||||
json: isJson,
|
|
||||||
emptyMessage: `No tweets found for @${username}.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show pagination hint if there's more
|
// Show pagination hint if there's more
|
||||||
if (result.nextCursor && !cmdOpts.json && !cmdOpts.jsonFull) {
|
if (result.nextCursor && !cmdOpts.json && !cmdOpts.jsonFull) {
|
||||||
|
|||||||
@@ -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<UserLookupResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withUserLookup<TBase extends AbstractConstructor<TwitterClientBase>>(
|
||||||
|
Base: TBase,
|
||||||
|
): Mixin<TBase, TwitterClientUserLookupMethods> {
|
||||||
|
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<UserLookupResult> {
|
||||||
|
// 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<UserLookupResult> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { normalizeHandle } from './normalize-handle.js';
|
|
||||||
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
|
import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
|
||||||
import { TWITTER_API_BASE } from './twitter-client-constants.js';
|
import { TWITTER_API_BASE } from './twitter-client-constants.js';
|
||||||
import { buildUserTweetsFeatures } from './twitter-client-features.js';
|
import { buildUserTweetsFeatures } from './twitter-client-features.js';
|
||||||
@@ -21,17 +20,7 @@ export interface UserTweetsPaginationOptions extends UserTweetsFetchOptions {
|
|||||||
pageDelayMs?: number;
|
pageDelayMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Result of username to userId lookup */
|
|
||||||
export interface UserLookupResult {
|
|
||||||
success: boolean;
|
|
||||||
userId?: string;
|
|
||||||
username?: string;
|
|
||||||
name?: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TwitterClientUserTweetsMethods {
|
export interface TwitterClientUserTweetsMethods {
|
||||||
getUserIdByUsername(username: string): Promise<UserLookupResult>;
|
|
||||||
getUserTweets(userId: string, count?: number, options?: UserTweetsFetchOptions): Promise<SearchResult>;
|
getUserTweets(userId: string, count?: number, options?: UserTweetsFetchOptions): Promise<SearchResult>;
|
||||||
getUserTweetsPaged(userId: string, limit: number, options?: UserTweetsPaginationOptions): Promise<SearchResult>;
|
getUserTweetsPaged(userId: string, limit: number, options?: UserTweetsPaginationOptions): Promise<SearchResult>;
|
||||||
}
|
}
|
||||||
@@ -51,196 +40,10 @@ export function withUserTweets<TBase extends AbstractConstructor<TwitterClientBa
|
|||||||
return Array.from(new Set([primary, 'Wms1GvIiHXAPBaCr9KblaA']));
|
return Array.from(new Set([primary, 'Wms1GvIiHXAPBaCr9KblaA']));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getUserByScreenNameGraphQL(screenName: string): Promise<UserLookupResult> {
|
|
||||||
// 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<void> {
|
private async sleep(ms: number): Promise<void> {
|
||||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
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<UserLookupResult> {
|
|
||||||
// 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).
|
* Get tweets from a user's profile timeline (single page).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,9 +6,22 @@ import {
|
|||||||
TWITTER_API_BASE,
|
TWITTER_API_BASE,
|
||||||
} from './twitter-client-constants.js';
|
} from './twitter-client-constants.js';
|
||||||
import { buildFollowingFeatures } from './twitter-client-features.js';
|
import { buildFollowingFeatures } from './twitter-client-features.js';
|
||||||
import type { CurrentUserResult, FollowingResult } from './twitter-client-types.js';
|
import type { CurrentUserResult, FollowingResult, TwitterUser } from './twitter-client-types.js';
|
||||||
import { extractCursorFromInstructions, parseUsersFromInstructions } from './twitter-client-utils.js';
|
import { extractCursorFromInstructions, parseUsersFromInstructions } from './twitter-client-utils.js';
|
||||||
|
|
||||||
|
type RestUser = {
|
||||||
|
id_str?: string;
|
||||||
|
id?: string | number;
|
||||||
|
screen_name?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
followers_count?: number;
|
||||||
|
friends_count?: number;
|
||||||
|
verified?: boolean;
|
||||||
|
profile_image_url_https?: string;
|
||||||
|
created_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export interface TwitterClientUserMethods {
|
export interface TwitterClientUserMethods {
|
||||||
getCurrentUser(): Promise<CurrentUserResult>;
|
getCurrentUser(): Promise<CurrentUserResult>;
|
||||||
getFollowing(userId: string, count?: number, cursor?: string): Promise<FollowingResult>;
|
getFollowing(userId: string, count?: number, cursor?: string): Promise<FollowingResult>;
|
||||||
@@ -34,6 +47,29 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
|
|||||||
return Array.from(new Set([primary, 'kuFUYP9eV1FPoEy4N-pi7w']));
|
return Array.from(new Set([primary, 'kuFUYP9eV1FPoEy4N-pi7w']));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseUsersFromRestResponse(users: RestUser[] | undefined): TwitterUser[] {
|
||||||
|
return (users ?? [])
|
||||||
|
.map((u) => {
|
||||||
|
const id = typeof u.id_str === 'string' ? u.id_str : typeof u.id === 'number' ? String(u.id) : null;
|
||||||
|
const username = typeof u.screen_name === 'string' ? u.screen_name : null;
|
||||||
|
if (!id || !username) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
name: typeof u.name === 'string' && u.name.length > 0 ? u.name : username,
|
||||||
|
description: typeof u.description === 'string' ? u.description : undefined,
|
||||||
|
followersCount: typeof u.followers_count === 'number' ? u.followers_count : undefined,
|
||||||
|
followingCount: typeof u.friends_count === 'number' ? u.friends_count : undefined,
|
||||||
|
isBlueVerified: typeof u.verified === 'boolean' ? u.verified : undefined,
|
||||||
|
profileImageUrl: typeof u.profile_image_url_https === 'string' ? u.profile_image_url_https : undefined,
|
||||||
|
createdAt: typeof u.created_at === 'string' ? u.created_at : undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((u) => u !== null);
|
||||||
|
}
|
||||||
|
|
||||||
private async getFollowersViaRest(userId: string, count: number): Promise<FollowingResult> {
|
private async getFollowersViaRest(userId: string, count: number): Promise<FollowingResult> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
@@ -63,40 +99,10 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as {
|
const data = (await response.json()) as {
|
||||||
users?: Array<{
|
users?: RestUser[];
|
||||||
id_str?: string;
|
|
||||||
id?: string | number;
|
|
||||||
screen_name?: string;
|
|
||||||
name?: string;
|
|
||||||
description?: string;
|
|
||||||
followers_count?: number;
|
|
||||||
friends_count?: number;
|
|
||||||
verified?: boolean;
|
|
||||||
profile_image_url_https?: string;
|
|
||||||
created_at?: string;
|
|
||||||
}>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const users = (data.users ?? [])
|
const users = this.parseUsersFromRestResponse(data.users);
|
||||||
.map((u) => {
|
|
||||||
const id = typeof u.id_str === 'string' ? u.id_str : typeof u.id === 'number' ? String(u.id) : null;
|
|
||||||
const username = typeof u.screen_name === 'string' ? u.screen_name : null;
|
|
||||||
if (!id || !username) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
username,
|
|
||||||
name: typeof u.name === 'string' && u.name.length > 0 ? u.name : username,
|
|
||||||
description: typeof u.description === 'string' ? u.description : undefined,
|
|
||||||
followersCount: typeof u.followers_count === 'number' ? u.followers_count : undefined,
|
|
||||||
followingCount: typeof u.friends_count === 'number' ? u.friends_count : undefined,
|
|
||||||
isBlueVerified: typeof u.verified === 'boolean' ? u.verified : undefined,
|
|
||||||
profileImageUrl: typeof u.profile_image_url_https === 'string' ? u.profile_image_url_https : undefined,
|
|
||||||
createdAt: typeof u.created_at === 'string' ? u.created_at : undefined,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((u) => u !== null);
|
|
||||||
|
|
||||||
return { success: true, users };
|
return { success: true, users };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -136,40 +142,10 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = (await response.json()) as {
|
const data = (await response.json()) as {
|
||||||
users?: Array<{
|
users?: RestUser[];
|
||||||
id_str?: string;
|
|
||||||
id?: string | number;
|
|
||||||
screen_name?: string;
|
|
||||||
name?: string;
|
|
||||||
description?: string;
|
|
||||||
followers_count?: number;
|
|
||||||
friends_count?: number;
|
|
||||||
verified?: boolean;
|
|
||||||
profile_image_url_https?: string;
|
|
||||||
created_at?: string;
|
|
||||||
}>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const users = (data.users ?? [])
|
const users = this.parseUsersFromRestResponse(data.users);
|
||||||
.map((u) => {
|
|
||||||
const id = typeof u.id_str === 'string' ? u.id_str : typeof u.id === 'number' ? String(u.id) : null;
|
|
||||||
const username = typeof u.screen_name === 'string' ? u.screen_name : null;
|
|
||||||
if (!id || !username) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
username,
|
|
||||||
name: typeof u.name === 'string' && u.name.length > 0 ? u.name : username,
|
|
||||||
description: typeof u.description === 'string' ? u.description : undefined,
|
|
||||||
followersCount: typeof u.followers_count === 'number' ? u.followers_count : undefined,
|
|
||||||
followingCount: typeof u.friends_count === 'number' ? u.friends_count : undefined,
|
|
||||||
isBlueVerified: typeof u.verified === 'boolean' ? u.verified : undefined,
|
|
||||||
profileImageUrl: typeof u.profile_image_url_https === 'string' ? u.profile_image_url_https : undefined,
|
|
||||||
createdAt: typeof u.created_at === 'string' ? u.created_at : undefined,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((u) => u !== null);
|
|
||||||
|
|
||||||
return { success: true, users };
|
return { success: true, users };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { type TwitterClientPostingMethods, withPosting } from './twitter-client-
|
|||||||
import { type TwitterClientSearchMethods, withSearch } from './twitter-client-search.js';
|
import { type TwitterClientSearchMethods, withSearch } from './twitter-client-search.js';
|
||||||
import { type TwitterClientTimelineMethods, withTimelines } from './twitter-client-timelines.js';
|
import { type TwitterClientTimelineMethods, withTimelines } from './twitter-client-timelines.js';
|
||||||
import { type TwitterClientTweetDetailMethods, withTweetDetails } from './twitter-client-tweet-detail.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 TwitterClientUserTweetsMethods, withUserTweets } from './twitter-client-user-tweets.js';
|
||||||
import { type TwitterClientUserMethods, withUsers } from './twitter-client-users.js';
|
import { type TwitterClientUserMethods, withUsers } from './twitter-client-users.js';
|
||||||
|
|
||||||
@@ -21,12 +22,15 @@ type TwitterClientInstance = TwitterClientBase &
|
|||||||
TwitterClientTimelineMethods &
|
TwitterClientTimelineMethods &
|
||||||
TwitterClientTweetDetailMethods &
|
TwitterClientTweetDetailMethods &
|
||||||
TwitterClientUserMethods &
|
TwitterClientUserMethods &
|
||||||
|
TwitterClientUserLookupMethods &
|
||||||
TwitterClientUserTweetsMethods;
|
TwitterClientUserTweetsMethods;
|
||||||
|
|
||||||
const MixedTwitterClient = withUserTweets(
|
const MixedTwitterClient = withUserTweets(
|
||||||
withUsers(
|
withUserLookup(
|
||||||
withLists(
|
withUsers(
|
||||||
withHome(withTimelines(withSearch(withTweetDetails(withPosting(withBookmarks(withMedia(TwitterClientBase))))))),
|
withLists(
|
||||||
|
withHome(withTimelines(withSearch(withTweetDetails(withPosting(withBookmarks(withMedia(TwitterClientBase))))))),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
) as AbstractConstructor<TwitterClientInstance>;
|
) as AbstractConstructor<TwitterClientInstance>;
|
||||||
|
|||||||
@@ -281,7 +281,8 @@ d('live CLI (Twitter/X)', () => {
|
|||||||
const tweets = parseJson<Array<{ id?: string; author?: { username?: string } }>>(userTweets.stdout);
|
const tweets = parseJson<Array<{ id?: string; author?: { username?: string } }>>(userTweets.stdout);
|
||||||
expect(Array.isArray(tweets)).toBe(true);
|
expect(Array.isArray(tweets)).toBe(true);
|
||||||
expect(tweets.length).toBeGreaterThan(0);
|
expect(tweets.length).toBeGreaterThan(0);
|
||||||
expect(tweets[0].author?.username?.toLowerCase()).toBe(testHandle.toLowerCase());
|
const handleLower = testHandle.toLowerCase();
|
||||||
|
expect(tweets.some((t) => t.author?.username?.toLowerCase() === handleLower)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('user-tweets paged JSON returns { tweets, nextCursor }', async () => {
|
it('user-tweets paged JSON returns { tweets, nextCursor }', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { TwitterClient } from '../src/lib/twitter-client.js';
|
||||||
|
import { validCookies } from './twitter-client-fixtures.js';
|
||||||
|
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TwitterClient getUserIdByUsername', () => {
|
||||||
|
let mockFetch: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch as unknown as typeof fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves username to userId via GraphQL', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
result: {
|
||||||
|
__typename: 'User',
|
||||||
|
rest_id: '12345',
|
||||||
|
legacy: {
|
||||||
|
screen_name: 'testuser',
|
||||||
|
name: 'Test User',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.getUserIdByUsername('@testuser');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.userId).toBe('12345');
|
||||||
|
expect(result.username).toBe('testuser');
|
||||||
|
expect(result.name).toBe('Test User');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles username without @ prefix', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
result: {
|
||||||
|
__typename: 'User',
|
||||||
|
rest_id: '67890',
|
||||||
|
legacy: {
|
||||||
|
screen_name: 'anotheruser',
|
||||||
|
name: 'Another User',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.getUserIdByUsername('anotheruser');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.userId).toBe('67890');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error for invalid username format', async () => {
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
|
||||||
|
const result = await client.getUserIdByUsername('invalid handle with spaces');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('Invalid username');
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error for empty username', async () => {
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
|
||||||
|
const result = await client.getUserIdByUsername('');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('Invalid username');
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error for username exceeding 15 characters', async () => {
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
|
||||||
|
const result = await client.getUserIdByUsername('thisusernameiswaytoolong');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('Invalid username');
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error when user is unavailable', async () => {
|
||||||
|
mockFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
result: {
|
||||||
|
__typename: 'UserUnavailable',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.getUserIdByUsername('suspended');
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('not found or unavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries with fallback query IDs on 404', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
text: async () => 'Not found',
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
text: async () => 'Not found',
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
result: {
|
||||||
|
__typename: 'User',
|
||||||
|
rest_id: '99999',
|
||||||
|
legacy: {
|
||||||
|
screen_name: 'founduser',
|
||||||
|
name: 'Found User',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
|
const result = await client.getUserIdByUsername('founduser');
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.userId).toBe('99999');
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,162 +9,6 @@ afterEach(() => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('TwitterClient getUserIdByUsername', () => {
|
|
||||||
let mockFetch: ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockFetch = vi.fn();
|
|
||||||
global.fetch = mockFetch as unknown as typeof fetch;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves username to userId via GraphQL', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => ({
|
|
||||||
data: {
|
|
||||||
user: {
|
|
||||||
result: {
|
|
||||||
__typename: 'User',
|
|
||||||
rest_id: '12345',
|
|
||||||
legacy: {
|
|
||||||
screen_name: 'testuser',
|
|
||||||
name: 'Test User',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies: validCookies });
|
|
||||||
const result = await client.getUserIdByUsername('@testuser');
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.userId).toBe('12345');
|
|
||||||
expect(result.username).toBe('testuser');
|
|
||||||
expect(result.name).toBe('Test User');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles username without @ prefix', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => ({
|
|
||||||
data: {
|
|
||||||
user: {
|
|
||||||
result: {
|
|
||||||
__typename: 'User',
|
|
||||||
rest_id: '67890',
|
|
||||||
legacy: {
|
|
||||||
screen_name: 'anotheruser',
|
|
||||||
name: 'Another User',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies: validCookies });
|
|
||||||
const result = await client.getUserIdByUsername('anotheruser');
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.userId).toBe('67890');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns error for invalid username format', async () => {
|
|
||||||
const client = new TwitterClient({ cookies: validCookies });
|
|
||||||
|
|
||||||
const result = await client.getUserIdByUsername('invalid handle with spaces');
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.error).toContain('Invalid username');
|
|
||||||
expect(mockFetch).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns error for empty username', async () => {
|
|
||||||
const client = new TwitterClient({ cookies: validCookies });
|
|
||||||
|
|
||||||
const result = await client.getUserIdByUsername('');
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.error).toContain('Invalid username');
|
|
||||||
expect(mockFetch).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns error for username exceeding 15 characters', async () => {
|
|
||||||
const client = new TwitterClient({ cookies: validCookies });
|
|
||||||
|
|
||||||
const result = await client.getUserIdByUsername('thisusernameiswaytoolong');
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.error).toContain('Invalid username');
|
|
||||||
expect(mockFetch).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns error when user is unavailable', async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => ({
|
|
||||||
data: {
|
|
||||||
user: {
|
|
||||||
result: {
|
|
||||||
__typename: 'UserUnavailable',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies: validCookies });
|
|
||||||
const result = await client.getUserIdByUsername('suspended');
|
|
||||||
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.error).toContain('not found or unavailable');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retries with fallback query IDs on 404', async () => {
|
|
||||||
mockFetch
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: false,
|
|
||||||
status: 404,
|
|
||||||
text: async () => 'Not found',
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: false,
|
|
||||||
status: 404,
|
|
||||||
text: async () => 'Not found',
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
json: async () => ({
|
|
||||||
data: {
|
|
||||||
user: {
|
|
||||||
result: {
|
|
||||||
__typename: 'User',
|
|
||||||
rest_id: '99999',
|
|
||||||
legacy: {
|
|
||||||
screen_name: 'founduser',
|
|
||||||
name: 'Found User',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies: validCookies });
|
|
||||||
const result = await client.getUserIdByUsername('founduser');
|
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
expect(result.userId).toBe('99999');
|
|
||||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('TwitterClient getUserTweets', () => {
|
describe('TwitterClient getUserTweets', () => {
|
||||||
let mockFetch: ReturnType<typeof vi.fn>;
|
let mockFetch: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user