refactor: split twitter client

This commit is contained in:
Peter Steinberger
2026-01-01 13:58:08 +01:00
parent 51bf79d9ed
commit 7a4782ddc4
15 changed files with 2716 additions and 2663 deletions
+322
View File
@@ -0,0 +1,322 @@
import { TWITTER_API_BASE } from './twitter-client-constants.js';
import { buildArticleFeatures, buildArticleFieldToggles, buildTweetDetailFeatures } from './twitter-client-features.js';
import { type AbstractConstructor, type TwitterClientBase } from './twitter-client-base.js';
import type { GetTweetResult, GraphqlTweetResult, SearchResult } from './twitter-client-types.js';
import {
extractArticleText,
findTweetInInstructions,
firstText,
mapTweetResult,
parseTweetsFromInstructions,
} from './twitter-client-utils.js';
export function withTweetDetails<TBase extends AbstractConstructor<TwitterClientBase>>(Base: TBase) {
return class extends Base {
private async fetchUserArticlePlainText(
userId: string,
tweetId: string,
): Promise<{ title?: string; plainText?: string }> {
const variables = {
userId,
count: 20,
includePromotedContent: true,
withVoice: true,
withQuickPromoteEligibilityTweetFields: true,
withBirdwatchNotes: true,
withCommunity: true,
withSafetyModeUserFields: true,
withSuperFollowsUserFields: true,
withDownvotePerspective: false,
withReactionsMetadata: false,
withReactionsPerspective: false,
withSuperFollowsTweetFields: true,
withSuperFollowsReplyCount: false,
withClientEventToken: false,
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(buildArticleFeatures()),
fieldToggles: JSON.stringify(buildArticleFieldToggles()),
});
const queryId = await this.getQueryId('UserArticlesTweets');
const url = `${TWITTER_API_BASE}/${queryId}/UserArticlesTweets?${params.toString()}`;
try {
const response = await this.fetchWithTimeout(url, { method: 'GET', headers: this.getHeaders() });
if (!response.ok) {
return {};
}
const data = (await response.json()) as {
data?: {
user?: {
result?: {
timeline?: {
timeline?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: { result?: GraphqlTweetResult };
};
};
}>;
}>;
};
};
};
};
};
};
const instructions = data.data?.user?.result?.timeline?.timeline?.instructions ?? [];
for (const instruction of instructions) {
for (const entry of instruction.entries ?? []) {
const result = entry.content?.itemContent?.tweet_results?.result;
if (result?.rest_id !== tweetId) {
continue;
}
const articleResult = result.article?.article_results?.result;
const title = firstText(articleResult?.title, result.article?.title);
const plainText = firstText(articleResult?.plain_text, result.article?.plain_text);
return { title, plainText };
}
}
} catch {
return {};
}
return {};
}
private async fetchTweetDetail(tweetId: string): Promise<
| {
success: true;
data: {
tweetResult?: { result?: GraphqlTweetResult };
threaded_conversation_with_injections_v2?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>;
};
};
}
| { success: false; error: string }
> {
const variables = {
focalTweetId: tweetId,
with_rux_injections: false,
rankingMode: 'Relevance',
includePromotedContent: true,
withCommunity: true,
withQuickPromoteEligibilityTweetFields: true,
withBirdwatchNotes: true,
withVoice: true,
};
const features = {
...buildTweetDetailFeatures(),
articles_preview_enabled: true,
articles_rest_api_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
view_counts_everywhere_api_enabled: true,
longform_notetweets_consumption_enabled: true,
responsive_web_twitter_article_tweet_consumption_enabled: true,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
rweb_video_timestamps_enabled: true,
};
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
try {
const parseResponse = async (response: Response) => {
if (!response.ok) {
const text = await response.text();
return { success: false as const, error: `HTTP ${response.status}: ${text.slice(0, 200)}` };
}
const data = (await response.json()) as {
data?: {
tweetResult?: { result?: GraphqlTweetResult };
threaded_conversation_with_injections_v2?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>;
};
};
errors?: Array<{ message: string; code?: number }>;
};
if (data.errors && data.errors.length > 0) {
return { success: false as const, error: data.errors.map((e) => e.message).join(', ') };
}
return { success: true as const, data: data.data ?? {} };
};
let lastError: string | undefined;
let had404 = false;
const tryOnce = async () => {
const queryIds = await this.getTweetDetailQueryIds();
for (const queryId of queryIds) {
const url = `${TWITTER_API_BASE}/${queryId}/TweetDetail?${params.toString()}`;
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
if (response.status !== 404) {
return await parseResponse(response);
}
had404 = true;
const postResponse = await this.fetchWithTimeout(`${TWITTER_API_BASE}/${queryId}/TweetDetail`, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify({ variables, features, queryId }),
});
if (postResponse.status !== 404) {
return await parseResponse(postResponse);
}
lastError = 'HTTP 404';
}
return { success: false as const, error: lastError ?? 'Unknown error fetching tweet detail' };
};
const firstAttempt = await tryOnce();
if (firstAttempt.success) {
return firstAttempt;
}
if (had404) {
await this.refreshQueryIds();
return await tryOnce();
}
return firstAttempt;
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
/**
* Get tweet details by ID
*/
async getTweet(tweetId: string): Promise<GetTweetResult> {
const response = await this.fetchTweetDetail(tweetId);
if (!response.success) {
return response;
}
const tweetResult =
(response.data.tweetResult as { result?: GraphqlTweetResult } | undefined)?.result ??
findTweetInInstructions(
response.data.threaded_conversation_with_injections_v2?.instructions as
| Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
}>
| undefined,
tweetId,
);
const mapped = mapTweetResult(tweetResult, this.quoteDepth);
if (mapped) {
if (tweetResult?.article) {
const title = firstText(tweetResult.article.article_results?.result?.title, tweetResult.article.title);
const articleText = extractArticleText(tweetResult);
if (title && (!articleText || articleText.trim() === title.trim())) {
const userId = tweetResult.core?.user_results?.result?.rest_id;
if (userId) {
const fallback = await this.fetchUserArticlePlainText(userId, tweetId);
if (fallback.plainText) {
mapped.text = fallback.title ? `${fallback.title}\n\n${fallback.plainText}` : fallback.plainText;
}
}
}
}
return { success: true, tweet: mapped };
}
return { success: false, error: 'Tweet not found in response' };
}
/**
* Get replies to a tweet by ID
*/
async getReplies(tweetId: string): Promise<SearchResult> {
const response = await this.fetchTweetDetail(tweetId);
if (!response.success) {
return response;
}
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
const tweets = parseTweetsFromInstructions(instructions, this.quoteDepth);
const replies = tweets.filter((tweet) => tweet.inReplyToStatusId === tweetId);
return { success: true, tweets: replies };
}
/**
* Get full conversation thread for a tweet ID
*/
async getThread(tweetId: string): Promise<SearchResult> {
const response = await this.fetchTweetDetail(tweetId);
if (!response.success) {
return response;
}
const instructions = response.data.threaded_conversation_with_injections_v2?.instructions;
const tweets = parseTweetsFromInstructions(instructions, this.quoteDepth);
const target = tweets.find((t) => t.id === tweetId);
const rootId = target?.conversationId || tweetId;
const thread = tweets.filter((tweet) => tweet.conversationId === rootId);
thread.sort((a, b) => {
const aTime = a.createdAt ? Date.parse(a.createdAt) : 0;
const bTime = b.createdAt ? Date.parse(b.createdAt) : 0;
return aTime - bTime;
});
return { success: true, tweets: thread };
}
};
}