feat: add media extraction support for photos, videos, and GIFs
This commit is contained in:
committed by
Peter Steinberger
parent
e537e102e3
commit
067c98c245
@@ -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?: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user