feat: add media extraction support for photos, videos, and GIFs

This commit is contained in:
Nikita
2026-01-03 18:34:32 +01:00
committed by Peter Steinberger
parent e537e102e3
commit 067c98c245
2 changed files with 110 additions and 57 deletions
+43 -24
View File
@@ -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?: {
+67 -33
View File
@@ -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<string>();
@@ -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;
}