Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const KNOWN_COMMANDS = new Set([
'likes',
'lists',
'list-timeline',
'list-members',
'home',
'user-tweets',
'news',
Expand Down
159 changes: 158 additions & 1 deletion src/commands/lists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down Expand Up @@ -152,4 +165,148 @@ export function registerListsCommand(program: Command, ctx: CliContext): void {
}
},
);

program
.command('list-members <list-id-or-url>')
.description('Get members of a list')
.option('-n, --count <number>', 'Number of members to fetch per page', '20')
.option('--cursor <string>', 'Resume pagination from a cursor')
.option('--all', 'Fetch all members (paginate automatically)')
.option('--max-pages <number>', '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/<id>.`);
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<string>();
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);
}
},
);
}
1 change: 1 addition & 0 deletions src/lib/twitter-client-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
116 changes: 114 additions & 2 deletions src/lib/twitter-client-lists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ListsResult>;
getListMemberships(count?: number): Promise<ListsResult>;
getListTimeline(listId: string, count?: number, options?: TimelineFetchOptions): Promise<SearchResult>;
getAllListTimeline(listId: string, options?: TimelinePaginationOptions): Promise<SearchResult>;
getListMembers(listId: string, count?: number, cursor?: string): Promise<ListMembersResult>;
}

interface GraphqlListResult {
Expand Down Expand Up @@ -118,6 +130,11 @@ export function withLists<TBase extends AbstractConstructor<TwitterClientBase>>(
return Array.from(new Set([primary, '2TemLyqrMpTeAmysdbnVqw']));
}

private async getListMembersQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('ListMembers');
return Array.from(new Set([primary, 'PIWQLsXHcxJvzjJwP7s2PQ']));
}

/**
* Get lists owned by the authenticated user
*/
Expand Down Expand Up @@ -486,6 +503,101 @@ export function withLists<TBase extends AbstractConstructor<TwitterClientBase>>(

return { success: true, tweets, nextCursor };
}

/**
* Get members of a list
*/
async getListMembers(listId: string, count = 20, cursor?: string): Promise<ListMembersResult> {
const variables: Record<string, unknown> = {
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<unknown> }>;
};
};
};
};
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;
Expand Down
7 changes: 7 additions & 0 deletions src/lib/twitter-client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: {
Expand Down