refactor: split twitter client
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user