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';
|
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 = {
|
export type GraphqlTweetResult = {
|
||||||
__typename?: string;
|
__typename?: string;
|
||||||
rest_id?: string;
|
rest_id?: string;
|
||||||
@@ -11,6 +34,12 @@ export type GraphqlTweetResult = {
|
|||||||
favorite_count?: number;
|
favorite_count?: number;
|
||||||
conversation_id_str?: string;
|
conversation_id_str?: string;
|
||||||
in_reply_to_status_id_str?: string | null;
|
in_reply_to_status_id_str?: string | null;
|
||||||
|
entities?: {
|
||||||
|
media?: GraphqlMediaEntity[];
|
||||||
|
};
|
||||||
|
extended_entities?: {
|
||||||
|
media?: GraphqlMediaEntity[];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
core?: {
|
core?: {
|
||||||
user_results?: {
|
user_results?: {
|
||||||
@@ -168,6 +197,18 @@ export interface UploadMediaResult {
|
|||||||
error?: string;
|
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 {
|
export interface TweetData {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -184,9 +225,8 @@ export interface TweetData {
|
|||||||
inReplyToStatusId?: string;
|
inReplyToStatusId?: string;
|
||||||
// Optional quoted tweet; depth controlled by quoteDepth (default: 1).
|
// Optional quoted tweet; depth controlled by quoteDepth (default: 1).
|
||||||
quotedTweet?: TweetData;
|
quotedTweet?: TweetData;
|
||||||
// Raw GraphQL response (included when --json-full is used).
|
// Media attachments (photos, videos, GIFs)
|
||||||
// Structure may change as Twitter's API evolves.
|
media?: TweetMedia[];
|
||||||
_raw?: GraphqlTweetResult;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetTweetResult {
|
export interface GetTweetResult {
|
||||||
@@ -237,27 +277,6 @@ export interface TwitterClientOptions {
|
|||||||
quoteDepth?: number;
|
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 {
|
export interface CreateTweetResponse {
|
||||||
data?: {
|
data?: {
|
||||||
create_tweet?: {
|
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 {
|
export function normalizeQuoteDepth(value?: number): number {
|
||||||
if (value === undefined || value === null) {
|
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);
|
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 {
|
export function unwrapTweetResult(result: GraphqlTweetResult | undefined): GraphqlTweetResult | undefined {
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -163,19 +221,7 @@ export function unwrapTweetResult(result: GraphqlTweetResult | undefined): Graph
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MapTweetResultOptions {
|
export function mapTweetResult(result: GraphqlTweetResult | undefined, quoteDepth: number): TweetData | undefined {
|
||||||
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;
|
|
||||||
|
|
||||||
const userResult = result?.core?.user_results?.result;
|
const userResult = result?.core?.user_results?.result;
|
||||||
const userLegacy = userResult?.legacy;
|
const userLegacy = userResult?.legacy;
|
||||||
const userCore = userResult?.core;
|
const userCore = userResult?.core;
|
||||||
@@ -195,11 +241,13 @@ export function mapTweetResult(
|
|||||||
if (quoteDepth > 0) {
|
if (quoteDepth > 0) {
|
||||||
const quotedResult = unwrapTweetResult(result.quoted_status_result?.result);
|
const quotedResult = unwrapTweetResult(result.quoted_status_result?.result);
|
||||||
if (quotedResult) {
|
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,
|
id: result.rest_id,
|
||||||
text,
|
text,
|
||||||
createdAt: result.legacy?.created_at,
|
createdAt: result.legacy?.created_at,
|
||||||
@@ -214,13 +262,8 @@ export function mapTweetResult(
|
|||||||
},
|
},
|
||||||
authorId: userId,
|
authorId: userId,
|
||||||
quotedTweet,
|
quotedTweet,
|
||||||
|
media,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (includeRaw) {
|
|
||||||
tweetData._raw = result;
|
|
||||||
}
|
|
||||||
|
|
||||||
return tweetData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findTweetInInstructions(
|
export function findTweetInInstructions(
|
||||||
@@ -312,11 +355,6 @@ export function collectTweetResultsFromEntry(entry: {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParseTweetsOptions {
|
|
||||||
quoteDepth: number;
|
|
||||||
includeRaw?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseTweetsFromInstructions(
|
export function parseTweetsFromInstructions(
|
||||||
instructions:
|
instructions:
|
||||||
| Array<{
|
| Array<{
|
||||||
@@ -359,12 +397,8 @@ export function parseTweetsFromInstructions(
|
|||||||
}>;
|
}>;
|
||||||
}>
|
}>
|
||||||
| undefined,
|
| undefined,
|
||||||
quoteDepthOrOptions: number | ParseTweetsOptions,
|
quoteDepth: number,
|
||||||
): TweetData[] {
|
): TweetData[] {
|
||||||
const options: ParseTweetsOptions =
|
|
||||||
typeof quoteDepthOrOptions === 'number' ? { quoteDepth: quoteDepthOrOptions } : quoteDepthOrOptions;
|
|
||||||
const { quoteDepth, includeRaw = false } = options;
|
|
||||||
|
|
||||||
const tweets: TweetData[] = [];
|
const tweets: TweetData[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
@@ -372,7 +406,7 @@ export function parseTweetsFromInstructions(
|
|||||||
for (const entry of instruction.entries ?? []) {
|
for (const entry of instruction.entries ?? []) {
|
||||||
const results = collectTweetResultsFromEntry(entry);
|
const results = collectTweetResultsFromEntry(entry);
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
const mapped = mapTweetResult(result, { quoteDepth, includeRaw });
|
const mapped = mapTweetResult(result, quoteDepth);
|
||||||
if (!mapped || seen.has(mapped.id)) {
|
if (!mapped || seen.has(mapped.id)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user