feat(twitter): improve graphql search and threads

This commit is contained in:
Peter Steinberger
2025-12-20 11:29:02 +01:00
parent c0d08f3235
commit bc7139dff9
3 changed files with 960 additions and 161 deletions
+5 -5
View File
@@ -1,7 +1,7 @@
{
"CreateTweet": "TAJw1rBsjAtdNgTdlo2oeg",
"CreateRetweet": "ojPdsZsimiJrUGLR1sjUtA",
"FavoriteTweet": "lI07N6Otwv1PhnEgXILM7A",
"TweetDetail": "nBS-WpgA6ZG0CyNHD517JQ",
"SearchTimeline": "Tp1sewRU1AsZpBWhqCZicQ"
"CreateTweet": "2BA2DIWTRVxHgKc4Xh2uKQ",
"CreateRetweet": "Uf3io9zVp1DsYxrmL5FJ7g",
"FavoriteTweet": "kuFUYP9eV1FPoEy4N-pi7w",
"TweetDetail": "aFvUsJm2c-oDkJV75blV6g",
"SearchTimeline": "M1jEez78PEfVfbQLvlWMvQ"
}
+646 -153
View File
@@ -15,7 +15,8 @@ const FALLBACK_QUERY_IDS = {
CreateRetweet: 'ojPdsZsimiJrUGLR1sjUtA',
FavoriteTweet: 'lI07N6Otwv1PhnEgXILM7A',
TweetDetail: 'nBS-WpgA6ZG0CyNHD517JQ',
SearchTimeline: 'Tp1sewRU1AsZpBWhqCZicQ',
SearchTimeline: 'M1jEez78PEfVfbQLvlWMvQ',
UserArticlesTweets: '8zBy9h4L90aDL02RsBcCFg',
} as const;
type OperationName = keyof typeof FALLBACK_QUERY_IDS;
@@ -25,6 +26,10 @@ const QUERY_IDS: Record<OperationName, string> = {
...(queryIds as Partial<Record<OperationName, string>>),
};
const SEARCH_TIMELINE_QUERY_IDS = Array.from(
new Set([QUERY_IDS.SearchTimeline, 'M1jEez78PEfVfbQLvlWMvQ', '5h0kNbk3ii97rmfY6CdgAA', 'Tp1sewRU1AsZpBWhqCZicQ']),
);
type GraphqlTweetResult = {
rest_id?: string;
legacy?: {
@@ -39,13 +44,137 @@ type GraphqlTweetResult = {
core?: {
user_results?: {
result?: {
rest_id?: string;
id?: string;
legacy?: {
screen_name?: string;
name?: string;
};
core?: {
screen_name?: string;
name?: string;
};
};
};
};
note_tweet?: {
note_tweet_results?: {
result?: {
text?: string;
richtext?: {
text?: string;
};
rich_text?: {
text?: string;
};
content?: {
text?: string;
richtext?: {
text?: string;
};
rich_text?: {
text?: string;
};
};
};
};
};
article?: {
article_results?: {
result?: {
title?: string;
plain_text?: string;
text?: string;
richtext?: {
text?: string;
};
rich_text?: {
text?: string;
};
body?: {
text?: string;
richtext?: {
text?: string;
};
rich_text?: {
text?: string;
};
};
content?: {
text?: string;
richtext?: {
text?: string;
};
rich_text?: {
text?: string;
};
items?: Array<{
text?: string;
content?: {
text?: string;
richtext?: { text?: string };
rich_text?: { text?: string };
};
}>;
};
sections?: Array<{
items?: Array<{
text?: string;
content?: {
text?: string;
richtext?: { text?: string };
rich_text?: { text?: string };
};
}>;
}>;
};
};
title?: string;
plain_text?: string;
text?: string;
richtext?: {
text?: string;
};
rich_text?: {
text?: string;
};
body?: {
text?: string;
richtext?: {
text?: string;
};
rich_text?: {
text?: string;
};
};
content?: {
text?: string;
richtext?: {
text?: string;
};
rich_text?: {
text?: string;
};
items?: Array<{
text?: string;
content?: {
text?: string;
richtext?: { text?: string };
rich_text?: { text?: string };
};
}>;
};
sections?: Array<{
items?: Array<{
text?: string;
content?: {
text?: string;
richtext?: { text?: string };
rich_text?: { text?: string };
};
}>;
}>;
};
};
export interface TweetResult {
@@ -61,6 +190,7 @@ export interface TweetData {
username: string;
name: string;
};
authorId?: string;
createdAt?: string;
replyCount?: number;
retweetCount?: number;
@@ -94,6 +224,7 @@ export interface CurrentUserResult {
export interface TwitterClientOptions {
cookies: TwitterCookies;
userAgent?: string;
timeoutMs?: number;
}
interface CreateTweetResponse {
@@ -116,6 +247,7 @@ export class TwitterClient {
private authToken: string;
private ct0: string;
private userAgent: string;
private timeoutMs?: number;
constructor(options: TwitterClientOptions) {
if (!options.cookies.authToken || !options.cookies.ct0) {
@@ -126,6 +258,21 @@ export class TwitterClient {
this.userAgent =
options.userAgent ||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
this.timeoutMs = options.timeoutMs;
}
private async fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
if (!this.timeoutMs || this.timeoutMs <= 0) {
return fetch(url, init);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
}
private findTweetInInstructions(
@@ -135,26 +282,7 @@ export class TwitterClient {
content?: {
itemContent?: {
tweet_results?: {
result?: {
rest_id?: string;
legacy?: {
full_text?: string;
created_at?: string;
reply_count?: number;
retweet_count?: number;
favorite_count?: number;
};
core?: {
user_results?: {
result?: {
legacy?: {
screen_name?: string;
name?: string;
};
};
};
};
};
result?: GraphqlTweetResult;
};
};
};
@@ -193,24 +321,221 @@ export class TwitterClient {
};
}
private firstText(...values: Array<string | undefined | null>): string | undefined {
for (const value of values) {
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed) return trimmed;
}
}
return undefined;
}
private collectTextFields(value: unknown, keys: Set<string>, output: string[]): void {
if (!value) return;
if (typeof value === 'string') return;
if (Array.isArray(value)) {
for (const item of value) {
this.collectTextFields(item, keys, output);
}
return;
}
if (typeof value === 'object') {
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
if (keys.has(key)) {
if (typeof nested === 'string') {
const trimmed = nested.trim();
if (trimmed) output.push(trimmed);
continue;
}
}
this.collectTextFields(nested, keys, output);
}
}
}
private uniqueOrdered(values: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
if (seen.has(value)) continue;
seen.add(value);
result.push(value);
}
return result;
}
private extractArticleText(result: GraphqlTweetResult | undefined): string | undefined {
const article = result?.article;
if (!article) return undefined;
const articleResult = article.article_results?.result ?? article;
if (process.env.BIRD_DEBUG_ARTICLE === '1') {
console.error(
'[bird][debug][article] payload:',
JSON.stringify(
{
rest_id: result?.rest_id,
article: articleResult,
note_tweet: result?.note_tweet?.note_tweet_results?.result ?? null,
},
null,
2,
),
);
}
const title = this.firstText(articleResult.title, article.title);
let body = this.firstText(
articleResult.plain_text,
article.plain_text,
articleResult.body?.text,
articleResult.body?.richtext?.text,
articleResult.body?.rich_text?.text,
articleResult.content?.text,
articleResult.content?.richtext?.text,
articleResult.content?.rich_text?.text,
articleResult.text,
articleResult.richtext?.text,
articleResult.rich_text?.text,
article.body?.text,
article.body?.richtext?.text,
article.body?.rich_text?.text,
article.content?.text,
article.content?.richtext?.text,
article.content?.rich_text?.text,
article.text,
article.richtext?.text,
article.rich_text?.text,
);
if (body && title && body.trim() === title.trim()) {
body = undefined;
}
if (!body) {
const collected: string[] = [];
this.collectTextFields(articleResult, new Set(['text', 'title']), collected);
this.collectTextFields(article, new Set(['text', 'title']), collected);
const unique = this.uniqueOrdered(collected);
const filtered = title ? unique.filter((value) => value !== title) : unique;
if (filtered.length > 0) {
body = filtered.join('\n\n');
}
}
if (title && body && !body.startsWith(title)) {
return `${title}\n\n${body}`;
}
return body ?? title;
}
private extractNoteTweetText(result: GraphqlTweetResult | undefined): string | undefined {
const note = result?.note_tweet?.note_tweet_results?.result;
if (!note) return undefined;
return this.firstText(
note.text,
note.richtext?.text,
note.rich_text?.text,
note.content?.text,
note.content?.richtext?.text,
note.content?.rich_text?.text,
);
}
private extractTweetText(result: GraphqlTweetResult | undefined): string | undefined {
return (
this.extractArticleText(result) ?? this.extractNoteTweetText(result) ?? this.firstText(result?.legacy?.full_text)
);
}
private mapTweetResult(result: GraphqlTweetResult | undefined): TweetData | undefined {
if (!result?.legacy || !result.core?.user_results?.result?.legacy?.screen_name) return undefined;
const userResult = result?.core?.user_results?.result;
const userLegacy = userResult?.legacy;
const userCore = userResult?.core;
const username = userLegacy?.screen_name ?? userCore?.screen_name;
const name = userLegacy?.name ?? userCore?.name ?? username;
const userId = userResult?.rest_id;
if (!result?.rest_id || !username) return undefined;
const text = this.extractTweetText(result);
if (!text) return undefined;
return {
id: result.rest_id || '',
text: result.legacy.full_text || '',
createdAt: result.legacy.created_at,
replyCount: result.legacy.reply_count,
retweetCount: result.legacy.retweet_count,
likeCount: result.legacy.favorite_count,
conversationId: result.legacy.conversation_id_str,
inReplyToStatusId: result.legacy.in_reply_to_status_id_str ?? undefined,
id: result.rest_id,
text,
createdAt: result.legacy?.created_at,
replyCount: result.legacy?.reply_count,
retweetCount: result.legacy?.retweet_count,
likeCount: result.legacy?.favorite_count,
conversationId: result.legacy?.conversation_id_str,
inReplyToStatusId: result.legacy?.in_reply_to_status_id_str ?? undefined,
author: {
username: result.core.user_results.result.legacy.screen_name,
name: result.core.user_results.result.legacy.name || result.core.user_results.result.legacy.screen_name,
username,
name: name || username,
},
authorId: userId,
};
}
private collectTweetResultsFromEntry(entry: {
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
item?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
items?: Array<{
item?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
};
}): GraphqlTweetResult[] {
const results: GraphqlTweetResult[] = [];
const pushResult = (result?: GraphqlTweetResult) => {
if (result?.rest_id) results.push(result);
};
const content = entry.content;
pushResult(content?.itemContent?.tweet_results?.result);
pushResult(content?.item?.itemContent?.tweet_results?.result);
for (const item of content?.items ?? []) {
pushResult(item?.item?.itemContent?.tweet_results?.result);
pushResult(item?.itemContent?.tweet_results?.result);
pushResult(item?.content?.itemContent?.tweet_results?.result);
}
return results;
}
private parseTweetsFromInstructions(
instructions:
| Array<{
@@ -221,22 +546,224 @@ export class TwitterClient {
result?: GraphqlTweetResult;
};
};
item?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
items?: Array<{
item?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
content?: {
itemContent?: {
tweet_results?: {
result?: GraphqlTweetResult;
};
};
};
}>;
};
}>;
}>
| undefined,
): TweetData[] {
const tweets: TweetData[] = [];
const seen = new Set<string>();
for (const instruction of instructions ?? []) {
for (const entry of instruction.entries ?? []) {
const result = entry.content?.itemContent?.tweet_results?.result;
const mapped = this.mapTweetResult(result);
if (mapped) tweets.push(mapped);
const results = this.collectTweetResultsFromEntry(entry);
for (const result of results) {
const mapped = this.mapTweetResult(result);
if (!mapped || seen.has(mapped.id)) continue;
seen.add(mapped.id);
tweets.push(mapped);
}
}
}
return tweets;
}
private buildArticleFeatures(): Record<string, boolean> {
return {
rweb_video_screen_enabled: true,
profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: true,
rweb_tipjar_consumption_enabled: true,
verified_phone_label_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
premium_content_api_read_enabled: false,
communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
responsive_web_grok_analyze_post_followups_enabled: false,
responsive_web_jetfuel_frame: true,
responsive_web_grok_share_attachment_enabled: true,
articles_preview_enabled: true,
responsive_web_edit_tweet_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,
tweet_awards_web_tipping_enabled: false,
responsive_web_grok_show_grok_translated_post: false,
responsive_web_grok_analysis_button_from_backend: true,
creator_subscriptions_quote_tweet_preview_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true,
standardized_nudges_misinfo: true,
tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true,
responsive_web_grok_image_annotation_enabled: true,
responsive_web_grok_imagine_annotation_enabled: true,
responsive_web_grok_community_note_auto_translation_is_enabled: false,
responsive_web_enhance_cards_enabled: false,
};
}
private buildArticleFieldToggles(): Record<string, boolean> {
return {
withPayments: false,
withAuxiliaryUserLabels: false,
withArticleRichContentState: true,
withArticlePlainText: true,
withGrokAnalyze: false,
withDisallowedReplyControls: false,
};
}
private buildSearchFeatures(): Record<string, boolean> {
return {
rweb_video_screen_enabled: true,
profile_label_improvements_pcf_label_in_post_enabled: true,
responsive_web_profile_redirect_enabled: true,
rweb_tipjar_consumption_enabled: true,
verified_phone_label_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_exclude_directive_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
premium_content_api_read_enabled: false,
communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
responsive_web_grok_analyze_button_fetch_trends_enabled: false,
responsive_web_grok_analyze_post_followups_enabled: false,
responsive_web_jetfuel_frame: true,
responsive_web_grok_share_attachment_enabled: true,
responsive_web_edit_tweet_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,
tweet_awards_web_tipping_enabled: false,
responsive_web_grok_show_grok_translated_post: false,
responsive_web_grok_analysis_button_from_backend: true,
creator_subscriptions_quote_tweet_preview_enabled: false,
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,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true,
responsive_web_grok_image_annotation_enabled: true,
responsive_web_grok_imagine_annotation_enabled: true,
responsive_web_grok_community_note_auto_translation_is_enabled: false,
articles_preview_enabled: true,
responsive_web_enhance_cards_enabled: false,
};
}
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(this.buildArticleFeatures()),
fieldToggles: JSON.stringify(this.buildArticleFieldToggles()),
});
const url = `${TWITTER_API_BASE}/${QUERY_IDS.UserArticlesTweets}/UserArticlesTweets?${params}`;
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 = this.firstText(articleResult?.title, result.article?.title);
const plainText = this.firstText(articleResult?.plain_text, result.article?.plain_text);
return { title, plainText };
}
}
} catch {
return {};
}
return {};
}
private async fetchTweetDetail(tweetId: string): Promise<
| {
success: true;
@@ -280,11 +807,15 @@ export class TwitterClient {
communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
articles_preview_enabled: true,
articles_rest_api_enabled: true,
responsive_web_edit_tweet_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,
responsive_web_twitter_article_plain_text_enabled: true,
responsive_web_twitter_article_seed_tweet_detail_enabled: true,
responsive_web_twitter_article_seed_tweet_summary_enabled: true,
tweet_awards_web_tipping_enabled: false,
creator_subscriptions_quote_tweet_preview_enabled: false,
freedom_of_speech_not_reach_fetch_enabled: true,
@@ -304,7 +835,7 @@ export class TwitterClient {
const url = `${TWITTER_API_BASE}/${QUERY_IDS.TweetDetail}/TweetDetail?${params}`;
try {
const response = await fetch(url, {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
@@ -374,6 +905,19 @@ export class TwitterClient {
const mapped = this.mapTweetResult(tweetResult);
if (mapped) {
if (tweetResult?.article) {
const title = this.firstText(tweetResult.article.article_results?.result?.title, tweetResult.article.title);
const articleText = this.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' };
@@ -500,7 +1044,7 @@ export class TwitterClient {
});
try {
const response = await fetch(url, {
const response = await this.fetchWithTimeout(url, {
method: 'POST',
headers: this.getHeaders(),
body,
@@ -554,79 +1098,60 @@ export class TwitterClient {
product: 'Latest',
};
const features = {
rweb_tipjar_consumption_enabled: true,
responsive_web_graphql_exclude_directive_enabled: true,
verified_phone_label_enabled: false,
creator_subscriptions_tweet_preview_api_enabled: true,
responsive_web_graphql_timeline_navigation_enabled: true,
responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
communities_web_enable_tweet_community_results_fetch: true,
c9s_tweet_anatomy_moderator_badge_enabled: true,
articles_preview_enabled: true,
responsive_web_edit_tweet_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,
tweet_awards_web_tipping_enabled: false,
creator_subscriptions_quote_tweet_preview_enabled: false,
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,
longform_notetweets_rich_text_read_enabled: true,
longform_notetweets_inline_media_enabled: true,
responsive_web_enhance_cards_enabled: false,
};
const features = this.buildSearchFeatures();
const params = new URLSearchParams({
variables: JSON.stringify(variables),
features: JSON.stringify(features),
});
let lastError: string | undefined;
const url = `${TWITTER_API_BASE}/${QUERY_IDS.SearchTimeline}/SearchTimeline?${params}`;
try {
const response = await fetch(url, {
method: 'GET',
headers: this.getHeaders(),
for (const queryId of SEARCH_TIMELINE_QUERY_IDS) {
const params = new URLSearchParams({
variables: JSON.stringify(variables),
});
const url = `${TWITTER_API_BASE}/${queryId}/SearchTimeline?${params}`;
if (!response.ok) {
const text = await response.text();
return {
success: false,
error: `HTTP ${response.status}: ${text.slice(0, 200)}`,
};
}
try {
const response = await this.fetchWithTimeout(url, {
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify({ features, queryId }),
});
const data = (await response.json()) as {
data?: {
search_by_raw_query?: {
search_timeline?: {
timeline?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: {
rest_id?: string;
legacy?: {
full_text?: string;
created_at?: string;
reply_count?: number;
retweet_count?: number;
favorite_count?: number;
in_reply_to_status_id_str?: string;
};
core?: {
user_results?: {
result?: {
legacy?: {
screen_name?: string;
name?: string;
if (response.status === 404) {
lastError = `HTTP ${response.status}`;
continue;
}
if (!response.ok) {
const text = await response.text();
return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` };
}
const data = (await response.json()) as {
data?: {
search_by_raw_query?: {
search_timeline?: {
timeline?: {
instructions?: Array<{
entries?: Array<{
content?: {
itemContent?: {
tweet_results?: {
result?: {
rest_id?: string;
legacy?: {
full_text?: string;
created_at?: string;
reply_count?: number;
retweet_count?: number;
favorite_count?: number;
in_reply_to_status_id_str?: string;
};
core?: {
user_results?: {
result?: {
legacy?: {
screen_name?: string;
name?: string;
};
};
};
};
@@ -634,61 +1159,29 @@ export class TwitterClient {
};
};
};
};
}>;
}>;
}>;
};
};
};
};
errors?: Array<{ message: string }>;
};
errors?: Array<{ message: string }>;
};
if (data.errors && data.errors.length > 0) {
return {
success: false,
error: data.errors.map((e) => e.message).join(', '),
};
}
const tweets: TweetData[] = [];
const instructions = data.data?.search_by_raw_query?.search_timeline?.timeline?.instructions || [];
for (const instruction of instructions) {
for (const entry of instruction.entries || []) {
const result = entry.content?.itemContent?.tweet_results?.result;
if (!result) continue;
const legacy = result.legacy;
const userLegacy = result.core?.user_results?.result?.legacy;
if (legacy?.full_text && userLegacy?.screen_name) {
tweets.push({
id: result.rest_id || '',
text: legacy.full_text,
author: {
username: userLegacy.screen_name,
name: userLegacy.name || userLegacy.screen_name,
},
createdAt: legacy.created_at,
replyCount: legacy.reply_count,
retweetCount: legacy.retweet_count,
likeCount: legacy.favorite_count,
});
}
if (data.errors && data.errors.length > 0) {
return { success: false, error: data.errors.map((e) => e.message).join(', ') };
}
}
return {
success: true,
tweets,
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
const instructions = data.data?.search_by_raw_query?.search_timeline?.timeline?.instructions;
const tweets = this.parseTweetsFromInstructions(instructions);
return { success: true, tweets };
} catch (error) {
lastError = error instanceof Error ? error.message : String(error);
}
}
return { success: false, error: lastError ?? 'Unknown error fetching search results' };
}
/**
@@ -706,7 +1199,7 @@ export class TwitterClient {
for (const url of candidateUrls) {
try {
const response = await fetch(url, {
const response = await this.fetchWithTimeout(url, {
method: 'GET',
headers: this.getHeaders(),
});
@@ -772,7 +1265,7 @@ export class TwitterClient {
const profilePages = ['https://x.com/settings/account', 'https://twitter.com/settings/account'];
for (const page of profilePages) {
try {
const response = await fetch(page, {
const response = await this.fetchWithTimeout(page, {
headers: {
cookie: `auth_token=${this.authToken}; ct0=${this.ct0}`,
'user-agent': this.userAgent,