From b35e60c171edf6c717824a03d81bd01f52d23da3 Mon Sep 17 00:00:00 2001 From: bee Date: Mon, 19 Jan 2026 14:17:56 -0500 Subject: [PATCH] feat: add feature to list members from a X list --- src/cli/program.ts | 1 + src/commands/lists.ts | 159 +++++++++++++++++++++++++++- src/lib/twitter-client-constants.ts | 1 + src/lib/twitter-client-lists.ts | 116 +++++++++++++++++++- src/lib/twitter-client-types.ts | 7 ++ 5 files changed, 281 insertions(+), 3 deletions(-) diff --git a/src/cli/program.ts b/src/cli/program.ts index 6c5c605..525b440 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -34,6 +34,7 @@ export const KNOWN_COMMANDS = new Set([ 'likes', 'lists', 'list-timeline', + 'list-members', 'home', 'user-tweets', 'news', diff --git a/src/commands/lists.ts b/src/commands/lists.ts index d7297b7..99642f9 100644 --- a/src/commands/lists.ts +++ b/src/commands/lists.ts @@ -6,9 +6,22 @@ import { parsePaginationFlags } from '../cli/pagination.js'; import type { CliContext } from '../cli/shared.js'; import { extractListId } from '../lib/extract-list-id.js'; import { hyperlink } from '../lib/output.js'; -import type { TwitterList } from '../lib/twitter-client.js'; +import type { TwitterList, TwitterUser } from '../lib/twitter-client.js'; import { TwitterClient } from '../lib/twitter-client.js'; +function printUsers(users: TwitterUser[], ctx: CliContext): void { + for (const user of users) { + console.log(`@${user.username} (${user.name})`); + if (user.description) { + console.log(` ${user.description.slice(0, 100)}${user.description.length > 100 ? '...' : ''}`); + } + if (user.followersCount !== undefined) { + console.log(` ${ctx.p('info')}${user.followersCount.toLocaleString()} followers`); + } + console.log('──────────────────────────────────────────────────'); + } +} + function printLists(lists: TwitterList[], ctx: CliContext): void { if (lists.length === 0) { console.log('No lists found.'); @@ -152,4 +165,148 @@ export function registerListsCommand(program: Command, ctx: CliContext): void { } }, ); + + program + .command('list-members ') + .description('Get members of a list') + .option('-n, --count ', 'Number of members to fetch per page', '20') + .option('--cursor ', 'Resume pagination from a cursor') + .option('--all', 'Fetch all members (paginate automatically)') + .option('--max-pages ', 'Stop after N pages when using --all') + .option('--json', 'Output as JSON') + .action( + async ( + listIdOrUrl: string, + cmdOpts: { + count?: string; + cursor?: string; + all?: boolean; + maxPages?: string; + json?: boolean; + }, + ) => { + const opts = program.opts(); + const timeoutMs = ctx.resolveTimeoutFromOptions(opts); + const count = Number.parseInt(cmdOpts.count || '20', 10); + const maxPages = cmdOpts.maxPages ? Number.parseInt(cmdOpts.maxPages, 10) : undefined; + + const listId = extractListId(listIdOrUrl); + if (!listId) { + console.error(`${ctx.p('err')}Invalid list ID or URL. Expected numeric ID or https://x.com/i/lists/.`); + process.exit(2); + } + + const usePagination = cmdOpts.all || cmdOpts.cursor; + if (maxPages !== undefined && !cmdOpts.all) { + console.error(`${ctx.p('err')}--max-pages requires --all.`); + process.exit(1); + } + if (maxPages !== undefined && (!Number.isFinite(maxPages) || maxPages <= 0)) { + console.error(`${ctx.p('err')}Invalid --max-pages. Expected a positive integer.`); + process.exit(1); + } + if (!usePagination && (!Number.isFinite(count) || count <= 0)) { + console.error(`${ctx.p('err')}Invalid --count. Expected a positive integer.`); + process.exit(1); + } + + const { cookies, warnings } = await ctx.resolveCredentialsFromOptions(opts); + + for (const warning of warnings) { + console.error(`${ctx.p('warn')}${warning}`); + } + + if (!cookies.authToken || !cookies.ct0) { + console.error(`${ctx.p('err')}Missing required credentials`); + process.exit(1); + } + + const client = new TwitterClient({ cookies, timeoutMs }); + + if (cmdOpts.all) { + const allUsers: TwitterUser[] = []; + const seen = new Set(); + let cursor: string | undefined = cmdOpts.cursor; + let pageNum = 0; + let nextCursor: string | undefined; + + while (true) { + pageNum += 1; + if (!cmdOpts.json) { + console.error(`${ctx.p('info')}Fetching page ${pageNum}...`); + } + + const result = await client.getListMembers(listId, count, cursor); + + if (!result.success || !result.users) { + console.error(`${ctx.p('err')}Failed to fetch list members: ${result.error}`); + process.exit(1); + } + + let added = 0; + for (const user of result.users) { + if (!seen.has(user.id)) { + seen.add(user.id); + allUsers.push(user); + added += 1; + } + } + + const pageCursor = result.nextCursor; + if (!pageCursor || result.users.length === 0 || added === 0 || pageCursor === cursor) { + nextCursor = undefined; + break; + } + + if (maxPages && pageNum >= maxPages) { + nextCursor = pageCursor; + break; + } + + cursor = pageCursor; + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + if (cmdOpts.json) { + console.log(JSON.stringify({ users: allUsers, nextCursor: nextCursor ?? null }, null, 2)); + } else { + console.error(`${ctx.p('info')}Total: ${allUsers.length} members`); + if (nextCursor) { + console.error(`${ctx.p('info')}Stopped at --max-pages. Use --cursor to continue.`); + console.error(`${ctx.p('info')}Next cursor: ${nextCursor}`); + } + if (allUsers.length === 0) { + console.log('No members found in this list.'); + } else { + printUsers(allUsers, ctx); + } + } + + return; + } + + const result = await client.getListMembers(listId, count, cmdOpts.cursor); + if (result.success && result.users) { + if (cmdOpts.json) { + if (usePagination) { + console.log(JSON.stringify({ users: result.users, nextCursor: result.nextCursor ?? null }, null, 2)); + } else { + console.log(JSON.stringify(result.users, null, 2)); + } + } else { + if (result.users.length === 0) { + console.log('No members found in this list.'); + } else { + printUsers(result.users, ctx); + if (result.nextCursor) { + console.error(`${ctx.p('info')}Next cursor: ${result.nextCursor}`); + } + } + } + } else { + console.error(`${ctx.p('err')}Failed to fetch list members: ${result.error}`); + process.exit(1); + } + }, + ); } diff --git a/src/lib/twitter-client-constants.ts b/src/lib/twitter-client-constants.ts index bcc70cc..f839370 100644 --- a/src/lib/twitter-client-constants.ts +++ b/src/lib/twitter-client-constants.ts @@ -36,6 +36,7 @@ export const FALLBACK_QUERY_IDS = { ListMemberships: 'BlEXXdARdSeL_0KyKHHvvg', ListLatestTweetsTimeline: '2TemLyqrMpTeAmysdbnVqw', ListByRestId: 'wXzyA5vM_aVkBL9G8Vp3kw', + ListMembers: 'PIWQLsXHcxJvzjJwP7s2PQ', HomeTimeline: 'edseUwk9sP5Phz__9TIRnA', HomeLatestTimeline: 'iOEZpOdfekFsxSlPQCQtPg', ExploreSidebar: 'lpSN4M6qpimkF4nRFPE3nQ', diff --git a/src/lib/twitter-client-lists.ts b/src/lib/twitter-client-lists.ts index 877d5a3..ef92538 100644 --- a/src/lib/twitter-client-lists.ts +++ b/src/lib/twitter-client-lists.ts @@ -5,14 +5,26 @@ import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-cl import { TWITTER_API_BASE } from './twitter-client-constants.js'; import { buildListsFeatures } from './twitter-client-features.js'; import type { TimelineFetchOptions, TimelinePaginationOptions } from './twitter-client-timelines.js'; -import type { GraphqlTweetResult, ListsResult, SearchResult, TweetData, TwitterList } from './twitter-client-types.js'; -import { extractCursorFromInstructions, parseTweetsFromInstructions } from './twitter-client-utils.js'; +import type { + GraphqlTweetResult, + ListMembersResult, + ListsResult, + SearchResult, + TweetData, + TwitterList, +} from './twitter-client-types.js'; +import { + extractCursorFromInstructions, + parseTweetsFromInstructions, + parseUsersFromInstructions, +} from './twitter-client-utils.js'; export interface TwitterClientListMethods { getOwnedLists(count?: number): Promise; getListMemberships(count?: number): Promise; getListTimeline(listId: string, count?: number, options?: TimelineFetchOptions): Promise; getAllListTimeline(listId: string, options?: TimelinePaginationOptions): Promise; + getListMembers(listId: string, count?: number, cursor?: string): Promise; } interface GraphqlListResult { @@ -118,6 +130,11 @@ export function withLists>( return Array.from(new Set([primary, '2TemLyqrMpTeAmysdbnVqw'])); } + private async getListMembersQueryIds(): Promise { + const primary = await this.getQueryId('ListMembers'); + return Array.from(new Set([primary, 'PIWQLsXHcxJvzjJwP7s2PQ'])); + } + /** * Get lists owned by the authenticated user */ @@ -486,6 +503,101 @@ export function withLists>( return { success: true, tweets, nextCursor }; } + + /** + * Get members of a list + */ + async getListMembers(listId: string, count = 20, cursor?: string): Promise { + const variables: Record = { + listId, + count, + withSafetyModeUserFields: true, + }; + + if (cursor) { + variables.cursor = cursor; + } + + const features = buildListsFeatures(); + + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(features), + }); + + const tryOnce = async () => { + let lastError: string | undefined; + let had404 = false; + const queryIds = await this.getListMembersQueryIds(); + + for (const queryId of queryIds) { + const url = `${TWITTER_API_BASE}/${queryId}/ListMembers?${params.toString()}`; + + try { + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getHeaders(), + }); + + if (response.status === 404) { + had404 = true; + lastError = `HTTP ${response.status}`; + continue; + } + + if (!response.ok) { + const text = await response.text(); + return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}`, had404 }; + } + + const data = (await response.json()) as { + data?: { + list?: { + members_timeline?: { + timeline?: { + instructions?: Array<{ type?: string; entries?: Array }>; + }; + }; + }; + }; + errors?: Array<{ message: string }>; + }; + + if (data.errors && data.errors.length > 0) { + return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 }; + } + + const instructions = data.data?.list?.members_timeline?.timeline?.instructions; + const users = parseUsersFromInstructions(instructions); + const nextCursor = extractCursorFromInstructions( + instructions as Array<{ entries?: Array<{ content?: unknown }> }> | undefined, + ); + + return { success: true as const, users, nextCursor, had404 }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + } + + return { success: false as const, error: lastError ?? 'Unknown error fetching list members', had404 }; + }; + + const firstAttempt = await tryOnce(); + if (firstAttempt.success) { + return { success: true, users: firstAttempt.users, nextCursor: firstAttempt.nextCursor }; + } + + if (firstAttempt.had404) { + await this.refreshQueryIds(); + const secondAttempt = await tryOnce(); + if (secondAttempt.success) { + return { success: true, users: secondAttempt.users, nextCursor: secondAttempt.nextCursor }; + } + return { success: false, error: secondAttempt.error }; + } + + return { success: false, error: firstAttempt.error }; + } } return TwitterClientLists; diff --git a/src/lib/twitter-client-types.ts b/src/lib/twitter-client-types.ts index a9639d7..8d3ab84 100644 --- a/src/lib/twitter-client-types.ts +++ b/src/lib/twitter-client-types.ts @@ -387,6 +387,13 @@ export interface ListsResult { error?: string; } +export interface ListMembersResult { + success: boolean; + users?: TwitterUser[]; + nextCursor?: string; + error?: string; +} + export interface CreateTweetResponse { data?: { create_tweet?: {