feat: add following/followers query refresh + retry

This commit is contained in:
Peter Steinberger
2026-01-01 11:25:21 +01:00
parent 34f45fece7
commit 9d715fb3a6
3 changed files with 148 additions and 61 deletions
+2
View File
@@ -14,6 +14,8 @@ const TARGET_OPERATIONS = [
'TweetDetail', 'TweetDetail',
'SearchTimeline', 'SearchTimeline',
'Bookmarks', 'Bookmarks',
'Following',
'Followers',
] as const; ] as const;
type OperationName = (typeof TARGET_OPERATIONS)[number]; type OperationName = (typeof TARGET_OPERATIONS)[number];
+3
View File
@@ -349,6 +349,9 @@ program
'SearchTimeline', 'SearchTimeline',
'UserArticlesTweets', 'UserArticlesTweets',
'Bookmarks', 'Bookmarks',
'Following',
'Followers',
'Likes',
]; ];
if (cmdOpts.fresh) { if (cmdOpts.fresh) {
+143 -61
View File
@@ -2047,6 +2047,16 @@ export class TwitterClient {
}; };
} }
private async getFollowingQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('Following');
return Array.from(new Set([primary, 'BEkNpEt5pNETESoqMsTEGA']));
}
private async getFollowersQueryIds(): Promise<string[]> {
const primary = await this.getQueryId('Followers');
return Array.from(new Set([primary, 'kuFUYP9eV1FPoEy4N-pi7w']));
}
private parseUsersFromInstructions( private parseUsersFromInstructions(
instructions: Array<{ type?: string; entries?: Array<unknown> }> | undefined, instructions: Array<{ type?: string; entries?: Array<unknown> }> | undefined,
): TwitterUser[] { ): TwitterUser[] {
@@ -2057,17 +2067,18 @@ export class TwitterClient {
const users: TwitterUser[] = []; const users: TwitterUser[] = [];
for (const instruction of instructions) { for (const instruction of instructions) {
if (instruction.type !== 'TimelineAddEntries' || !instruction.entries) { if (!instruction.entries) {
continue; continue;
} }
for (const entry of instruction.entries) { for (const entry of instruction.entries) {
const content = (entry as { content?: { itemContent?: { user_results?: { result?: unknown } } } })?.content; const content = (entry as { content?: { itemContent?: { user_results?: { result?: unknown } } } })?.content;
const userResult = content?.itemContent?.user_results?.result as const rawUserResult = content?.itemContent?.user_results?.result as
| { | {
__typename?: string; __typename?: string;
rest_id?: string; rest_id?: string;
is_blue_verified?: boolean; is_blue_verified?: boolean;
user?: unknown;
legacy?: { legacy?: {
screen_name?: string; screen_name?: string;
name?: string; name?: string;
@@ -2088,17 +2099,26 @@ export class TwitterClient {
} }
| undefined; | undefined;
const userResult =
rawUserResult?.__typename === 'UserWithVisibilityResults' && rawUserResult.user
? (rawUserResult.user as typeof rawUserResult)
: rawUserResult;
if (!userResult || userResult.__typename !== 'User') { if (!userResult || userResult.__typename !== 'User') {
continue; continue;
} }
const legacy = userResult.legacy; const legacy = userResult.legacy;
const core = userResult.core; const core = userResult.core;
const username = legacy?.screen_name ?? core?.screen_name;
if (!userResult.rest_id || !username) {
continue;
}
users.push({ users.push({
id: userResult.rest_id ?? '', id: userResult.rest_id,
username: legacy?.screen_name ?? core?.screen_name ?? '', username,
name: legacy?.name ?? core?.name ?? '', name: legacy?.name ?? core?.name ?? username,
description: legacy?.description, description: legacy?.description,
followers_count: legacy?.followers_count, followers_count: legacy?.followers_count,
following_count: legacy?.friends_count, following_count: legacy?.friends_count,
@@ -2129,46 +2149,77 @@ export class TwitterClient {
features: JSON.stringify(features), features: JSON.stringify(features),
}); });
const queryId = await this.getQueryId('Following'); const tryOnce = async () => {
const url = `${TWITTER_API_BASE}/${queryId}/Following?${params.toString()}`; let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getFollowingQueryIds();
try { for (const queryId of queryIds) {
const response = await this.fetchWithTimeout(url, { const url = `${TWITTER_API_BASE}/${queryId}/Following?${params.toString()}`;
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) { try {
const text = await response.text(); const response = await this.fetchWithTimeout(url, {
return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` }; method: 'GET',
} headers: this.getHeaders(),
});
const data = (await response.json()) as { if (response.status === 404) {
data?: { had404 = true;
user?: { lastError = `HTTP ${response.status}`;
result?: { continue;
timeline?: { }
timeline?: {
instructions?: Array<{ type?: string; entries?: Array<unknown> }>; 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?: {
user?: {
result?: {
timeline?: {
timeline?: {
instructions?: Array<{ type?: string; entries?: Array<unknown> }>;
};
};
}; };
}; };
}; };
errors?: Array<{ message: string }>;
}; };
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) { if (data.errors && data.errors.length > 0) {
return { success: false, error: data.errors.map((e) => e.message).join(', ') }; return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
}
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
const users = this.parseUsersFromInstructions(instructions);
return { success: true as const, users, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
} }
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions; return { success: false as const, error: lastError ?? 'Unknown error fetching following', had404 };
const users = this.parseUsersFromInstructions(instructions); };
return { success: true, users }; const firstAttempt = await tryOnce();
} catch (error) { if (firstAttempt.success) {
return { success: false, error: error instanceof Error ? error.message : String(error) }; return { success: true, users: firstAttempt.users };
} }
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) {
return { success: true, users: secondAttempt.users };
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error };
} }
/** /**
@@ -2188,45 +2239,76 @@ export class TwitterClient {
features: JSON.stringify(features), features: JSON.stringify(features),
}); });
const queryId = await this.getQueryId('Followers'); const tryOnce = async () => {
const url = `${TWITTER_API_BASE}/${queryId}/Followers?${params.toString()}`; let lastError: string | undefined;
let had404 = false;
const queryIds = await this.getFollowersQueryIds();
try { for (const queryId of queryIds) {
const response = await this.fetchWithTimeout(url, { const url = `${TWITTER_API_BASE}/${queryId}/Followers?${params.toString()}`;
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) { try {
const text = await response.text(); const response = await this.fetchWithTimeout(url, {
return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` }; method: 'GET',
} headers: this.getHeaders(),
});
const data = (await response.json()) as { if (response.status === 404) {
data?: { had404 = true;
user?: { lastError = `HTTP ${response.status}`;
result?: { continue;
timeline?: { }
timeline?: {
instructions?: Array<{ type?: string; entries?: Array<unknown> }>; 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?: {
user?: {
result?: {
timeline?: {
timeline?: {
instructions?: Array<{ type?: string; entries?: Array<unknown> }>;
};
};
}; };
}; };
}; };
errors?: Array<{ message: string }>;
}; };
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) { if (data.errors && data.errors.length > 0) {
return { success: false, error: data.errors.map((e) => e.message).join(', ') }; return { success: false as const, error: data.errors.map((e) => e.message).join(', '), had404 };
}
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions;
const users = this.parseUsersFromInstructions(instructions);
return { success: true as const, users, had404 };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
} }
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions; return { success: false as const, error: lastError ?? 'Unknown error fetching followers', had404 };
const users = this.parseUsersFromInstructions(instructions); };
return { success: true, users }; const firstAttempt = await tryOnce();
} catch (error) { if (firstAttempt.success) {
return { success: false, error: error instanceof Error ? error.message : String(error) }; return { success: true, users: firstAttempt.users };
} }
if (firstAttempt.had404) {
await this.refreshQueryIds();
const secondAttempt = await tryOnce();
if (secondAttempt.success) {
return { success: true, users: secondAttempt.users };
}
return { success: false, error: secondAttempt.error };
}
return { success: false, error: firstAttempt.error };
} }
} }