Files
bird/src/lib/twitter-client-tweet-detail.ts
T
cc-vpsandChristian Catalan 3815ebfa03 feat: add --json-full flag to include raw API response in _raw field
Add a new --json-full flag to tweet-fetching commands that outputs JSON
with an additional _raw field containing the full GraphQL response.

This provides power users access to additional data not exposed in the
curated TweetData interface (media, entities, hashtags, cards, etc.)
while maintaining backward compatibility with --json.

Supported commands:
- read, replies, thread (TweetDetail API)
- search, mentions (SearchTimeline API)
- bookmarks (Bookmarks API)
- likes (Likes API)

Not supported: following, followers (uses mixed GraphQL/REST fallback
with different response structures)

Implementation:
- Add _raw?: GraphqlTweetResult optional field to TweetData type
- Add includeRaw option to mapTweetResult() and parseTweetsFromInstructions()
- Add TweetFetchOptions, SearchFetchOptions, TimelineFetchOptions interfaces
- Update client methods to accept options parameter
- Add --json-full flag to CLI commands
- Update help text to document the new flag
- Add comprehensive test coverage (16 tests)

Co-authored-by: Christian Catalan <[email protected]>
2026-01-03 06:54:36 +01:00

347 lines
12 KiB
TypeScript

import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js';
import { TWITTER_API_BASE } from './twitter-client-constants.js';
import { buildArticleFeatures, buildArticleFieldToggles, buildTweetDetailFeatures } from './twitter-client-features.js';
import type { GetTweetResult, GraphqlTweetResult, SearchResult } from './twitter-client-types.js';
import {
extractArticleText,
findTweetInInstructions,
firstText,
mapTweetResult,
parseTweetsFromInstructions,
} from './twitter-client-utils.js';
/** Options for tweet fetching methods */
export interface TweetFetchOptions {
/** Include raw GraphQL response in `_raw` field */
includeRaw?: boolean;
}
export interface TwitterClientTweetDetailMethods {
getTweet(tweetId: string, options?: TweetFetchOptions): Promise<GetTweetResult>;
getReplies(tweetId: string, options?: TweetFetchOptions): Promise<SearchResult>;
getThread(tweetId: string, options?: TweetFetchOptions): Promise<SearchResult>;
}
export function withTweetDetails<TBase extends AbstractConstructor<TwitterClientBase>>(
Base: TBase,
): Mixin<TBase, TwitterClientTweetDetailMethods> {
abstract class TwitterClientTweetDetails extends Base {
// biome-ignore lint/complexity/noUselessConstructor lint/suspicious/noExplicitAny: TS mixin constructor requirement.
constructor(...args: any[]) {
super(...args);
}
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, options: TweetFetchOptions = {}): Promise<GetTweetResult> {
const { includeRaw = false } = options;
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, { quoteDepth: this.quoteDepth, includeRaw });
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, options: TweetFetchOptions = {}): Promise<SearchResult> {
const { includeRaw = false } = options;
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, { quoteDepth: this.quoteDepth, includeRaw });
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, options: TweetFetchOptions = {}): Promise<SearchResult> {
const { includeRaw = false } = options;
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, { quoteDepth: this.quoteDepth, includeRaw });
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 };
}
}
return TwitterClientTweetDetails;
}