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>;
|
||||
loadMedia: (opts: { media: string[]; alts: string[] }) => MediaSpec[];
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
isTty,
|
||||
getOutput: () => output,
|
||||
@@ -402,6 +425,7 @@ export function createCliContext(normalizedArgs: string[], env: NodeJS.ProcessEn
|
||||
resolveCredentialsFromOptions,
|
||||
loadMedia,
|
||||
printTweets,
|
||||
printTweetsResult,
|
||||
extractTweetId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,11 +74,7 @@ export function registerBookmarksCommand(program: Command, ctx: CliContext): voi
|
||||
if (result.success && result.tweets) {
|
||||
const emptyMessage = folderId ? 'No bookmarks found in folder.' : 'No bookmarks found.';
|
||||
const isJson = cmdOpts.json || cmdOpts.jsonFull;
|
||||
if (isJson && usePagination) {
|
||||
console.log(JSON.stringify({ tweets: result.tweets, nextCursor: result.nextCursor ?? null }, null, 2));
|
||||
} else {
|
||||
ctx.printTweets(result.tweets, { json: isJson, emptyMessage });
|
||||
}
|
||||
ctx.printTweetsResult(result, { json: isJson, usePagination, emptyMessage });
|
||||
} else {
|
||||
console.error(`${ctx.p('err')}Failed to fetch bookmarks: ${result.error}`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -139,11 +139,11 @@ export function registerListsCommand(program: Command, ctx: CliContext): void {
|
||||
|
||||
if (result.success && result.tweets) {
|
||||
const isJson = cmdOpts.json || cmdOpts.jsonFull;
|
||||
if (isJson && usePagination) {
|
||||
console.log(JSON.stringify({ tweets: result.tweets, nextCursor: result.nextCursor ?? null }, null, 2));
|
||||
} else {
|
||||
ctx.printTweets(result.tweets, { json: isJson, emptyMessage: 'No tweets found in this list.' });
|
||||
}
|
||||
ctx.printTweetsResult(result, {
|
||||
json: isJson,
|
||||
usePagination,
|
||||
emptyMessage: 'No tweets found in this list.',
|
||||
});
|
||||
} else {
|
||||
console.error(`${ctx.p('err')}Failed to fetch list timeline: ${result.error}`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -115,15 +115,11 @@ export function registerUserTweetsCommand(program: Command, ctx: CliContext): vo
|
||||
|
||||
if (result.success && result.tweets) {
|
||||
const isJson = cmdOpts.json || cmdOpts.jsonFull;
|
||||
if (isJson && wantsPaginationOutput) {
|
||||
// Include nextCursor for pagination consumers
|
||||
console.log(JSON.stringify({ tweets: result.tweets, nextCursor: result.nextCursor ?? null }, null, 2));
|
||||
} else {
|
||||
ctx.printTweets(result.tweets, {
|
||||
json: isJson,
|
||||
emptyMessage: `No tweets found for @${username}.`,
|
||||
});
|
||||
}
|
||||
ctx.printTweetsResult(result, {
|
||||
json: isJson,
|
||||
usePagination: wantsPaginationOutput,
|
||||
emptyMessage: `No tweets found for @${username}.`,
|
||||
});
|
||||
|
||||
// Show pagination hint if there's more
|
||||
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 { 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<UserLookupResult>;
|
||||
getUserTweets(userId: string, count?: number, options?: UserTweetsFetchOptions): 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']));
|
||||
}
|
||||
|
||||
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> {
|
||||
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).
|
||||
*/
|
||||
|
||||
@@ -6,9 +6,22 @@ import {
|
||||
TWITTER_API_BASE,
|
||||
} from './twitter-client-constants.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';
|
||||
|
||||
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 {
|
||||
getCurrentUser(): Promise<CurrentUserResult>;
|
||||
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']));
|
||||
}
|
||||
|
||||
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> {
|
||||
const params = new URLSearchParams({
|
||||
user_id: userId,
|
||||
@@ -63,40 +99,10 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
users?: Array<{
|
||||
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;
|
||||
}>;
|
||||
users?: RestUser[];
|
||||
};
|
||||
|
||||
const users = (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);
|
||||
const users = this.parseUsersFromRestResponse(data.users);
|
||||
|
||||
return { success: true, users };
|
||||
} catch (error) {
|
||||
@@ -136,40 +142,10 @@ export function withUsers<TBase extends AbstractConstructor<TwitterClientBase>>(
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
users?: Array<{
|
||||
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;
|
||||
}>;
|
||||
users?: RestUser[];
|
||||
};
|
||||
|
||||
const users = (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);
|
||||
const users = this.parseUsersFromRestResponse(data.users);
|
||||
|
||||
return { success: true, users };
|
||||
} catch (error) {
|
||||
|
||||
@@ -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<TwitterClientInstance>;
|
||||
|
||||
@@ -281,7 +281,8 @@ d('live CLI (Twitter/X)', () => {
|
||||
const tweets = parseJson<Array<{ id?: string; author?: { username?: string } }>>(userTweets.stdout);
|
||||
expect(Array.isArray(tweets)).toBe(true);
|
||||
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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
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', () => {
|
||||
let mockFetch: ReturnType<typeof vi.fn>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user