From fe26c2b6398dba5d84ad0d5964fdbe6730db0a9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Liu=20=E5=AE=9D=E7=8E=89?= Date: Tue, 21 Apr 2026 13:39:01 -0500 Subject: [PATCH] fix(baoyu-fetch): extract X video variants --- .../src/__tests__/x-article.test.ts | 101 ++++++++++++++++++ .../src/__tests__/x-single.test.ts | 73 +++++++++++++ .../baoyu-fetch/src/adapters/x/article.ts | 68 ++++++++---- packages/baoyu-fetch/src/adapters/x/shared.ts | 41 ++++++- 4 files changed, 261 insertions(+), 22 deletions(-) diff --git a/packages/baoyu-fetch/src/__tests__/x-article.test.ts b/packages/baoyu-fetch/src/__tests__/x-article.test.ts index ace82c1..995e987 100644 --- a/packages/baoyu-fetch/src/__tests__/x-article.test.ts +++ b/packages/baoyu-fetch/src/__tests__/x-article.test.ts @@ -339,4 +339,105 @@ describe("x article extraction", () => { expect(content.markdown).toContain("https://example.com/report"); expect(content.markdown).not.toContain("https://t.co/example"); }); + + test("renders article video media as the highest bitrate mp4 link", () => { + const payload = { + data: { + tweetResult: { + result: { + rest_id: "2046628728210350366", + legacy: { + full_text: "Fallback text", + favorite_count: 12, + retweet_count: 3, + reply_count: 1, + created_at: "Tue Apr 21 16:34:47 +0000 2026", + }, + core: { + user_results: { + result: { + legacy: { + name: "Google AI Studio", + screen_name: "GoogleAIStudio", + }, + }, + }, + }, + article: { + article_results: { + result: { + title: "Article with video", + media_entities: [ + { + media_id: "2046627051822530560", + media_info: { + __typename: "ApiVideo", + variants: [ + { + bit_rate: 2176000, + content_type: "video/mp4", + url: "https://video.twimg.com/amplify_video/2046627051822530560/vid/avc1/1280x720/medium.mp4", + }, + { + content_type: "application/x-mpegURL", + url: "https://video.twimg.com/amplify_video/2046627051822530560/pl/playlist.m3u8", + }, + { + bit_rate: 10368000, + content_type: "video/mp4", + url: "https://video.twimg.com/amplify_video/2046627051822530560/vid/avc1/1920x1080/high.mp4", + }, + ], + }, + }, + ], + content_state: { + blocks: [ + { + type: "atomic", + text: " ", + data: {}, + entityRanges: [{ key: 0, length: 1, offset: 0 }], + inlineStyleRanges: [], + }, + ], + entityMap: [ + { + key: "0", + value: { + type: "MEDIA", + mutability: "Immutable", + data: { + mediaItems: [{ mediaId: "2046627051822530560" }], + }, + }, + }, + ], + }, + }, + }, + }, + }, + }, + }, + }; + + const document = extractArticleDocumentFromPayload( + payload, + "2046628728210350366", + "https://x.com/GoogleAIStudio/status/2046628728210350366", + ); + + expect(document).not.toBeNull(); + + const content = document?.content[0]; + expect(content?.type).toBe("markdown"); + if (!content || content.type !== "markdown") { + throw new Error("Expected markdown content"); + } + + expect(content.markdown).toBe( + "[video](https://video.twimg.com/amplify_video/2046627051822530560/vid/avc1/1920x1080/high.mp4)", + ); + }); }); diff --git a/packages/baoyu-fetch/src/__tests__/x-single.test.ts b/packages/baoyu-fetch/src/__tests__/x-single.test.ts index ccfbe5b..a777387 100644 --- a/packages/baoyu-fetch/src/__tests__/x-single.test.ts +++ b/packages/baoyu-fetch/src/__tests__/x-single.test.ts @@ -184,4 +184,77 @@ describe("x single tweet extraction", () => { "Quoted Author (@quoted_author)\n\nQuoted tweet text\n\nphoto: https://pbs.twimg.com/media/quoted?format=jpg&name=4096x4096", }); }); + + test("uses the highest bitrate mp4 variant for tweet video media", () => { + const payload = { + data: { + tweetResult: { + result: { + rest_id: "2046628728210350366", + legacy: { + full_text: "Video post https://t.co/video", + favorite_count: 12, + retweet_count: 3, + reply_count: 1, + created_at: "Tue Apr 21 16:34:47 +0000 2026", + extended_entities: { + media: [ + { + type: "video", + media_url_https: "https://pbs.twimg.com/amplify_video_thumb/2046627051822530560/img/poster.jpg", + url: "https://t.co/video", + video_info: { + variants: [ + { + content_type: "application/x-mpegURL", + url: "https://video.twimg.com/amplify_video/2046627051822530560/pl/playlist.m3u8", + }, + { + bitrate: 256000, + content_type: "video/mp4", + url: "https://video.twimg.com/amplify_video/2046627051822530560/vid/avc1/480x270/low.mp4", + }, + { + bitrate: 10368000, + content_type: "video/mp4", + url: "https://video.twimg.com/amplify_video/2046627051822530560/vid/avc1/1920x1080/high.mp4", + }, + ], + }, + }, + ], + }, + }, + core: { + user_results: { + result: { + legacy: { + name: "Google AI Studio", + screen_name: "GoogleAIStudio", + }, + }, + }, + }, + }, + }, + }, + }; + + const document = extractSingleTweetDocumentFromPayload( + payload, + "2046628728210350366", + "https://x.com/GoogleAIStudio/status/2046628728210350366", + ); + + expect(document).not.toBeNull(); + + const listBlock = document?.content.find((block) => block.type === "list"); + expect(listBlock).toEqual({ + type: "list", + ordered: false, + items: [ + "video: https://video.twimg.com/amplify_video/2046627051822530560/vid/avc1/1920x1080/high.mp4", + ], + }); + }); }); diff --git a/packages/baoyu-fetch/src/adapters/x/article.ts b/packages/baoyu-fetch/src/adapters/x/article.ts index 02635b0..7eafa58 100644 --- a/packages/baoyu-fetch/src/adapters/x/article.ts +++ b/packages/baoyu-fetch/src/adapters/x/article.ts @@ -9,18 +9,43 @@ import { getUser, isRecord, normalizeTitle, + resolveBestXVideoVariantUrl, toHighResXImageUrl, toXTweet, } from "./shared"; import type { JsonObject } from "./types"; -function resolveArticleMediaUrl(mediaInfo: JsonObject): string { +interface ArticleMedia { + kind: "image" | "video"; + url: string; +} + +function resolveArticleMedia(mediaInfo: JsonObject): ArticleMedia | null { + const videoUrl = resolveBestXVideoVariantUrl(mediaInfo); + if (videoUrl) { + return { + kind: "video", + url: videoUrl, + }; + } + const rawUrl = (typeof mediaInfo.original_img_url === "string" && mediaInfo.original_img_url) || (typeof mediaInfo.url === "string" && mediaInfo.url) || ""; - return rawUrl ? toHighResXImageUrl(rawUrl) : ""; + if (!rawUrl) { + return null; + } + + return { + kind: "image", + url: toHighResXImageUrl(rawUrl), + }; +} + +function resolveArticleMediaUrl(mediaInfo: JsonObject): string { + return resolveArticleMedia(mediaInfo)?.url ?? ""; } function normalizeEntityMap(entityMap: unknown): Map { @@ -139,8 +164,8 @@ function getTweetId(entityMap: Map, entityKey: unknown): str return data.tweetId; } -function buildMediaUrlMap(articleResult: JsonObject): Map { - const mediaMap = new Map(); +function buildMediaMap(articleResult: JsonObject): Map { + const mediaMap = new Map(); const mediaEntities = Array.isArray(articleResult.media_entities) ? articleResult.media_entities : []; for (const entity of mediaEntities) { @@ -148,25 +173,28 @@ function buildMediaUrlMap(articleResult: JsonObject): Map { continue; } - const mediaInfo = entity.media_info; - const url = resolveArticleMediaUrl(mediaInfo); - if (url) { - mediaMap.set(entity.media_id, url); + const media = resolveArticleMedia(entity.media_info); + if (media) { + mediaMap.set(entity.media_id, media); } } const coverMedia = isRecord(articleResult.cover_media) ? articleResult.cover_media : null; if (coverMedia && typeof coverMedia.media_id === "string" && isRecord(coverMedia.media_info)) { - const url = resolveArticleMediaUrl(coverMedia.media_info); - if (url) { - mediaMap.set(coverMedia.media_id, url); + const media = resolveArticleMedia(coverMedia.media_info); + if (media) { + mediaMap.set(coverMedia.media_id, media); } } return mediaMap; } -function getMediaMarkdown(entityMap: Map, entityKey: unknown, mediaMap: Map): string[] { +function getMediaMarkdown( + entityMap: Map, + entityKey: unknown, + mediaMap: Map, +): string[] { const key = typeof entityKey === "string" || typeof entityKey === "number" ? String(entityKey) @@ -182,19 +210,19 @@ function getMediaMarkdown(entityMap: Map, entityKey: unknown const data = isRecord(entity.data) ? entity.data : {}; const mediaItems = Array.isArray(data.mediaItems) ? data.mediaItems : []; - const urls: string[] = []; + const media: ArticleMedia[] = []; for (const item of mediaItems) { if (!isRecord(item) || typeof item.mediaId !== "string") { continue; } - const url = mediaMap.get(item.mediaId); - if (url && !urls.includes(url)) { - urls.push(url); + const mediaItem = mediaMap.get(item.mediaId); + if (mediaItem && !media.some((value) => value.url === mediaItem.url)) { + media.push(mediaItem); } } - return urls.map((url) => `![](${url})`); + return media.map((item) => item.kind === "image" ? `![](${item.url})` : `[video](${item.url})`); } function resolveTweetMarkdown(payloads: unknown[], tweetId: string, pageUrl: string): string | null { @@ -250,7 +278,7 @@ function replaceLinkEntities(text: string, block: JsonObject, entityMap: Map, - mediaMap: Map, + mediaMap: Map, payloads: unknown[], pageUrl: string, ): string | null { @@ -293,7 +321,7 @@ function renderAtomicBlock( function renderArticleBlocks( blocks: unknown[], entityMap: Map, - mediaMap: Map, + mediaMap: Map, payloads: unknown[], pageUrl: string, ): string { @@ -396,7 +424,7 @@ export function extractArticleDocumentFromPayload( const contentState = isRecord(articleResult.content_state) ? articleResult.content_state : {}; const blocks = Array.isArray(contentState.blocks) ? contentState.blocks : []; const entityMap = normalizeEntityMap(contentState.entityMap); - const mediaMap = buildMediaUrlMap(articleResult); + const mediaMap = buildMediaMap(articleResult); const richMarkdown = renderArticleBlocks(blocks, entityMap, mediaMap, payloads, pageUrl); const plainText = typeof articleResult.plain_text === "string" ? articleResult.plain_text.trim() : ""; const markdown = richMarkdown || plainText || getTweetText(tweet); diff --git a/packages/baoyu-fetch/src/adapters/x/shared.ts b/packages/baoyu-fetch/src/adapters/x/shared.ts index 90f3363..a0fef18 100644 --- a/packages/baoyu-fetch/src/adapters/x/shared.ts +++ b/packages/baoyu-fetch/src/adapters/x/shared.ts @@ -251,6 +251,39 @@ export function toHighResXImageUrl(rawUrl: string): string { } } +function getVideoVariantBitrate(variant: JsonObject): number { + const value = variant.bitrate ?? variant.bit_rate; + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function getVideoVariantContentType(variant: JsonObject): string { + const value = variant.content_type ?? variant.contentType; + return typeof value === "string" ? value.toLowerCase() : ""; +} + +export function resolveBestXVideoVariantUrl(mediaInfo: unknown): string | undefined { + if (!isRecord(mediaInfo)) { + return undefined; + } + + const variantsSource = + Array.isArray(mediaInfo.variants) + ? mediaInfo.variants + : isRecord(mediaInfo.video_info) && Array.isArray(mediaInfo.video_info.variants) + ? mediaInfo.video_info.variants + : []; + + const variants = variantsSource + .filter( + (variant): variant is JsonObject => + isRecord(variant) && typeof variant.url === "string" && variant.url.length > 0, + ) + .filter((variant) => getVideoVariantContentType(variant) === "video/mp4") + .sort((left, right) => getVideoVariantBitrate(right) - getVideoVariantBitrate(left)); + + return typeof variants[0]?.url === "string" ? variants[0].url : undefined; +} + export function getTweetMedia(tweet: JsonObject): XMedia[] { const legacy = getLegacy(tweet); const extendedEntities = isRecord(legacy.extended_entities) ? legacy.extended_entities : emptyObject(); @@ -268,10 +301,14 @@ export function getTweetMedia(tweet: JsonObject): XMedia[] { alt: typeof value.ext_alt_text === "string" ? value.ext_alt_text : undefined, }; } - if ((value.type === "video" || value.type === "animated_gif") && typeof value.media_url_https === "string") { + if (value.type === "video" || value.type === "animated_gif") { + const videoUrl = resolveBestXVideoVariantUrl(value); + if (!videoUrl) { + return null; + } return { type: value.type, - url: value.media_url_https, + url: videoUrl, }; } return null;