mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-25 03:19:48 +08:00
fix(baoyu-fetch): extract X video variants
This commit is contained in:
@@ -339,4 +339,105 @@ describe("x article extraction", () => {
|
|||||||
expect(content.markdown).toContain("https://example.com/report");
|
expect(content.markdown).toContain("https://example.com/report");
|
||||||
expect(content.markdown).not.toContain("https://t.co/example");
|
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)",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
"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",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,18 +9,43 @@ import {
|
|||||||
getUser,
|
getUser,
|
||||||
isRecord,
|
isRecord,
|
||||||
normalizeTitle,
|
normalizeTitle,
|
||||||
|
resolveBestXVideoVariantUrl,
|
||||||
toHighResXImageUrl,
|
toHighResXImageUrl,
|
||||||
toXTweet,
|
toXTweet,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
import type { JsonObject } from "./types";
|
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 =
|
const rawUrl =
|
||||||
(typeof mediaInfo.original_img_url === "string" && mediaInfo.original_img_url) ||
|
(typeof mediaInfo.original_img_url === "string" && mediaInfo.original_img_url) ||
|
||||||
(typeof mediaInfo.url === "string" && mediaInfo.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<string, JsonObject> {
|
function normalizeEntityMap(entityMap: unknown): Map<string, JsonObject> {
|
||||||
@@ -139,8 +164,8 @@ function getTweetId(entityMap: Map<string, JsonObject>, entityKey: unknown): str
|
|||||||
return data.tweetId;
|
return data.tweetId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildMediaUrlMap(articleResult: JsonObject): Map<string, string> {
|
function buildMediaMap(articleResult: JsonObject): Map<string, ArticleMedia> {
|
||||||
const mediaMap = new Map<string, string>();
|
const mediaMap = new Map<string, ArticleMedia>();
|
||||||
const mediaEntities = Array.isArray(articleResult.media_entities) ? articleResult.media_entities : [];
|
const mediaEntities = Array.isArray(articleResult.media_entities) ? articleResult.media_entities : [];
|
||||||
|
|
||||||
for (const entity of mediaEntities) {
|
for (const entity of mediaEntities) {
|
||||||
@@ -148,25 +173,28 @@ function buildMediaUrlMap(articleResult: JsonObject): Map<string, string> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mediaInfo = entity.media_info;
|
const media = resolveArticleMedia(entity.media_info);
|
||||||
const url = resolveArticleMediaUrl(mediaInfo);
|
if (media) {
|
||||||
if (url) {
|
mediaMap.set(entity.media_id, media);
|
||||||
mediaMap.set(entity.media_id, url);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const coverMedia = isRecord(articleResult.cover_media) ? articleResult.cover_media : null;
|
const coverMedia = isRecord(articleResult.cover_media) ? articleResult.cover_media : null;
|
||||||
if (coverMedia && typeof coverMedia.media_id === "string" && isRecord(coverMedia.media_info)) {
|
if (coverMedia && typeof coverMedia.media_id === "string" && isRecord(coverMedia.media_info)) {
|
||||||
const url = resolveArticleMediaUrl(coverMedia.media_info);
|
const media = resolveArticleMedia(coverMedia.media_info);
|
||||||
if (url) {
|
if (media) {
|
||||||
mediaMap.set(coverMedia.media_id, url);
|
mediaMap.set(coverMedia.media_id, media);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return mediaMap;
|
return mediaMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMediaMarkdown(entityMap: Map<string, JsonObject>, entityKey: unknown, mediaMap: Map<string, string>): string[] {
|
function getMediaMarkdown(
|
||||||
|
entityMap: Map<string, JsonObject>,
|
||||||
|
entityKey: unknown,
|
||||||
|
mediaMap: Map<string, ArticleMedia>,
|
||||||
|
): string[] {
|
||||||
const key =
|
const key =
|
||||||
typeof entityKey === "string" || typeof entityKey === "number"
|
typeof entityKey === "string" || typeof entityKey === "number"
|
||||||
? String(entityKey)
|
? String(entityKey)
|
||||||
@@ -182,19 +210,19 @@ function getMediaMarkdown(entityMap: Map<string, JsonObject>, entityKey: unknown
|
|||||||
|
|
||||||
const data = isRecord(entity.data) ? entity.data : {};
|
const data = isRecord(entity.data) ? entity.data : {};
|
||||||
const mediaItems = Array.isArray(data.mediaItems) ? data.mediaItems : [];
|
const mediaItems = Array.isArray(data.mediaItems) ? data.mediaItems : [];
|
||||||
const urls: string[] = [];
|
const media: ArticleMedia[] = [];
|
||||||
|
|
||||||
for (const item of mediaItems) {
|
for (const item of mediaItems) {
|
||||||
if (!isRecord(item) || typeof item.mediaId !== "string") {
|
if (!isRecord(item) || typeof item.mediaId !== "string") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const url = mediaMap.get(item.mediaId);
|
const mediaItem = mediaMap.get(item.mediaId);
|
||||||
if (url && !urls.includes(url)) {
|
if (mediaItem && !media.some((value) => value.url === mediaItem.url)) {
|
||||||
urls.push(url);
|
media.push(mediaItem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return urls.map((url) => ``);
|
return media.map((item) => item.kind === "image" ? `` : `[video](${item.url})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTweetMarkdown(payloads: unknown[], tweetId: string, pageUrl: string): string | null {
|
function resolveTweetMarkdown(payloads: unknown[], tweetId: string, pageUrl: string): string | null {
|
||||||
@@ -250,7 +278,7 @@ function replaceLinkEntities(text: string, block: JsonObject, entityMap: Map<str
|
|||||||
function renderAtomicBlock(
|
function renderAtomicBlock(
|
||||||
block: JsonObject,
|
block: JsonObject,
|
||||||
entityMap: Map<string, JsonObject>,
|
entityMap: Map<string, JsonObject>,
|
||||||
mediaMap: Map<string, string>,
|
mediaMap: Map<string, ArticleMedia>,
|
||||||
payloads: unknown[],
|
payloads: unknown[],
|
||||||
pageUrl: string,
|
pageUrl: string,
|
||||||
): string | null {
|
): string | null {
|
||||||
@@ -293,7 +321,7 @@ function renderAtomicBlock(
|
|||||||
function renderArticleBlocks(
|
function renderArticleBlocks(
|
||||||
blocks: unknown[],
|
blocks: unknown[],
|
||||||
entityMap: Map<string, JsonObject>,
|
entityMap: Map<string, JsonObject>,
|
||||||
mediaMap: Map<string, string>,
|
mediaMap: Map<string, ArticleMedia>,
|
||||||
payloads: unknown[],
|
payloads: unknown[],
|
||||||
pageUrl: string,
|
pageUrl: string,
|
||||||
): string {
|
): string {
|
||||||
@@ -396,7 +424,7 @@ export function extractArticleDocumentFromPayload(
|
|||||||
const contentState = isRecord(articleResult.content_state) ? articleResult.content_state : {};
|
const contentState = isRecord(articleResult.content_state) ? articleResult.content_state : {};
|
||||||
const blocks = Array.isArray(contentState.blocks) ? contentState.blocks : [];
|
const blocks = Array.isArray(contentState.blocks) ? contentState.blocks : [];
|
||||||
const entityMap = normalizeEntityMap(contentState.entityMap);
|
const entityMap = normalizeEntityMap(contentState.entityMap);
|
||||||
const mediaMap = buildMediaUrlMap(articleResult);
|
const mediaMap = buildMediaMap(articleResult);
|
||||||
const richMarkdown = renderArticleBlocks(blocks, entityMap, mediaMap, payloads, pageUrl);
|
const richMarkdown = renderArticleBlocks(blocks, entityMap, mediaMap, payloads, pageUrl);
|
||||||
const plainText = typeof articleResult.plain_text === "string" ? articleResult.plain_text.trim() : "";
|
const plainText = typeof articleResult.plain_text === "string" ? articleResult.plain_text.trim() : "";
|
||||||
const markdown = richMarkdown || plainText || getTweetText(tweet);
|
const markdown = richMarkdown || plainText || getTweetText(tweet);
|
||||||
|
|||||||
@@ -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[] {
|
export function getTweetMedia(tweet: JsonObject): XMedia[] {
|
||||||
const legacy = getLegacy(tweet);
|
const legacy = getLegacy(tweet);
|
||||||
const extendedEntities = isRecord(legacy.extended_entities) ? legacy.extended_entities : emptyObject();
|
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,
|
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 {
|
return {
|
||||||
type: value.type,
|
type: value.type,
|
||||||
url: value.media_url_https,
|
url: videoUrl,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user