diff --git a/src/lib/twitter-client-types.ts b/src/lib/twitter-client-types.ts index 630eb09..37215f0 100644 --- a/src/lib/twitter-client-types.ts +++ b/src/lib/twitter-client-types.ts @@ -1,5 +1,28 @@ import type { TwitterCookies } from './cookies.js'; +// Raw media entity from Twitter API +export interface GraphqlMediaEntity { + id_str?: string; + media_url_https?: string; + type?: 'photo' | 'video' | 'animated_gif'; + url?: string; + expanded_url?: string; + sizes?: { + thumb?: { w: number; h: number; resize: string }; + small?: { w: number; h: number; resize: string }; + medium?: { w: number; h: number; resize: string }; + large?: { w: number; h: number; resize: string }; + }; + video_info?: { + duration_millis?: number; + variants?: Array<{ + bitrate?: number; + content_type?: string; + url?: string; + }>; + }; +} + export type GraphqlTweetResult = { __typename?: string; rest_id?: string; @@ -11,6 +34,12 @@ export type GraphqlTweetResult = { favorite_count?: number; conversation_id_str?: string; in_reply_to_status_id_str?: string | null; + entities?: { + media?: GraphqlMediaEntity[]; + }; + extended_entities?: { + media?: GraphqlMediaEntity[]; + }; }; core?: { user_results?: { @@ -168,6 +197,18 @@ export interface UploadMediaResult { error?: string; } +// Parsed media item for output +export interface TweetMedia { + type: 'photo' | 'video' | 'animated_gif'; + url: string; + previewUrl?: string; + width?: number; + height?: number; + // For video/animated_gif: best quality video URL + videoUrl?: string; + durationMs?: number; +} + export interface TweetData { id: string; text: string; @@ -184,9 +225,8 @@ export interface TweetData { inReplyToStatusId?: string; // Optional quoted tweet; depth controlled by quoteDepth (default: 1). quotedTweet?: TweetData; - // Raw GraphQL response (included when --json-full is used). - // Structure may change as Twitter's API evolves. - _raw?: GraphqlTweetResult; + // Media attachments (photos, videos, GIFs) + media?: TweetMedia[]; } export interface GetTweetResult { @@ -237,27 +277,6 @@ export interface TwitterClientOptions { quoteDepth?: number; } -export interface TwitterList { - id: string; - name: string; - description?: string; - memberCount?: number; - subscriberCount?: number; - isPrivate?: boolean; - createdAt?: string; - owner?: { - id: string; - username: string; - name: string; - }; -} - -export interface ListsResult { - success: boolean; - lists?: TwitterList[]; - error?: string; -} - export interface CreateTweetResponse { data?: { create_tweet?: { diff --git a/src/lib/twitter-client-utils.ts b/src/lib/twitter-client-utils.ts index 26a5da9..a36d49e 100644 --- a/src/lib/twitter-client-utils.ts +++ b/src/lib/twitter-client-utils.ts @@ -1,4 +1,4 @@ -import type { GraphqlTweetResult, TweetData, TwitterUser } from './twitter-client-types.js'; +import type { GraphqlMediaEntity, GraphqlTweetResult, TweetData, TweetMedia, TwitterUser } from './twitter-client-types.js'; export function normalizeQuoteDepth(value?: number): number { if (value === undefined || value === null) { @@ -153,6 +153,64 @@ export function extractTweetText(result: GraphqlTweetResult | undefined): string return extractArticleText(result) ?? extractNoteTweetText(result) ?? firstText(result?.legacy?.full_text); } +export function extractMedia(result: GraphqlTweetResult | undefined): TweetMedia[] | undefined { + // Prefer extended_entities (has video info), fall back to entities + const rawMedia = result?.legacy?.extended_entities?.media ?? result?.legacy?.entities?.media; + if (!rawMedia || rawMedia.length === 0) { + return undefined; + } + + const media: TweetMedia[] = []; + + for (const item of rawMedia) { + if (!item.type || !item.media_url_https) { + continue; + } + + const mediaItem: TweetMedia = { + type: item.type, + url: item.media_url_https, + }; + + // Get dimensions from largest available size + const sizes = item.sizes; + if (sizes?.large) { + mediaItem.width = sizes.large.w; + mediaItem.height = sizes.large.h; + } else if (sizes?.medium) { + mediaItem.width = sizes.medium.w; + mediaItem.height = sizes.medium.h; + } + + // For thumbnails/previews + if (sizes?.small) { + mediaItem.previewUrl = `${item.media_url_https}:small`; + } + + // Extract video URL for video/animated_gif + if ((item.type === 'video' || item.type === 'animated_gif') && item.video_info?.variants) { + // Find highest bitrate mp4 variant + const mp4Variants = item.video_info.variants + .filter((v): v is { bitrate: number; content_type: string; url: string } => + v.content_type === 'video/mp4' && typeof v.bitrate === 'number' && typeof v.url === 'string' + ) + .sort((a, b) => b.bitrate - a.bitrate); + + if (mp4Variants.length > 0) { + mediaItem.videoUrl = mp4Variants[0].url; + } + + if (item.video_info.duration_millis) { + mediaItem.durationMs = item.video_info.duration_millis; + } + } + + media.push(mediaItem); + } + + return media.length > 0 ? media : undefined; +} + export function unwrapTweetResult(result: GraphqlTweetResult | undefined): GraphqlTweetResult | undefined { if (!result) { return undefined; @@ -163,19 +221,7 @@ export function unwrapTweetResult(result: GraphqlTweetResult | undefined): Graph return result; } -export interface MapTweetResultOptions { - quoteDepth: number; - includeRaw?: boolean; -} - -export function mapTweetResult( - result: GraphqlTweetResult | undefined, - quoteDepthOrOptions: number | MapTweetResultOptions, -): TweetData | undefined { - const options: MapTweetResultOptions = - typeof quoteDepthOrOptions === 'number' ? { quoteDepth: quoteDepthOrOptions } : quoteDepthOrOptions; - const { quoteDepth, includeRaw = false } = options; - +export function mapTweetResult(result: GraphqlTweetResult | undefined, quoteDepth: number): TweetData | undefined { const userResult = result?.core?.user_results?.result; const userLegacy = userResult?.legacy; const userCore = userResult?.core; @@ -195,11 +241,13 @@ export function mapTweetResult( if (quoteDepth > 0) { const quotedResult = unwrapTweetResult(result.quoted_status_result?.result); if (quotedResult) { - quotedTweet = mapTweetResult(quotedResult, { quoteDepth: quoteDepth - 1, includeRaw }); + quotedTweet = mapTweetResult(quotedResult, quoteDepth - 1); } } - const tweetData: TweetData = { + const media = extractMedia(result); + + return { id: result.rest_id, text, createdAt: result.legacy?.created_at, @@ -214,13 +262,8 @@ export function mapTweetResult( }, authorId: userId, quotedTweet, + media, }; - - if (includeRaw) { - tweetData._raw = result; - } - - return tweetData; } export function findTweetInInstructions( @@ -312,11 +355,6 @@ export function collectTweetResultsFromEntry(entry: { return results; } -export interface ParseTweetsOptions { - quoteDepth: number; - includeRaw?: boolean; -} - export function parseTweetsFromInstructions( instructions: | Array<{ @@ -359,12 +397,8 @@ export function parseTweetsFromInstructions( }>; }> | undefined, - quoteDepthOrOptions: number | ParseTweetsOptions, + quoteDepth: number, ): TweetData[] { - const options: ParseTweetsOptions = - typeof quoteDepthOrOptions === 'number' ? { quoteDepth: quoteDepthOrOptions } : quoteDepthOrOptions; - const { quoteDepth, includeRaw = false } = options; - const tweets: TweetData[] = []; const seen = new Set(); @@ -372,7 +406,7 @@ export function parseTweetsFromInstructions( for (const entry of instruction.entries ?? []) { const results = collectTweetResultsFromEntry(entry); for (const result of results) { - const mapped = mapTweetResult(result, { quoteDepth, includeRaw }); + const mapped = mapTweetResult(result, quoteDepth); if (!mapped || seen.has(mapped.id)) { continue; }