fix: YouTube 适配器改用搜索端点获取热门视频
get_trending_videos 端点当前返回空数据,改用 search_video 搜索热门关键词, 合并去重后按播放量排序。同时修复 fetchDetail 字段映射以匹配实际 API 响应格式。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
65a42c9b5c
commit
2e4a6dd8ee
@ -17,54 +17,46 @@ describe("YouTubeAdapter", () => {
|
||||
});
|
||||
|
||||
describe("fetchTrending", () => {
|
||||
it("returns mapped ContentItem[] from trending videos", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
it("returns mapped ContentItem[] from search results", async () => {
|
||||
const searchResult = {
|
||||
videos: [
|
||||
{
|
||||
video_id: "abc123",
|
||||
snippet: {
|
||||
title: "Test Video",
|
||||
channelTitle: "Test Channel",
|
||||
publishedAt: "2024-01-15T08:00:00Z",
|
||||
thumbnails: {
|
||||
high: { url: "https://img.youtube.com/high.jpg" },
|
||||
default: { url: "https://img.youtube.com/default.jpg" },
|
||||
},
|
||||
tags: ["music", "trending"],
|
||||
},
|
||||
statistics: {
|
||||
viewCount: "1500000",
|
||||
likeCount: "80000",
|
||||
commentCount: "3500",
|
||||
},
|
||||
title: "Test Video",
|
||||
author: "Test Channel",
|
||||
number_of_views: 1500000,
|
||||
published_time: "2 days ago",
|
||||
thumbnails: [
|
||||
{ url: "https://img.youtube.com/small.jpg", width: 360, height: 202 },
|
||||
{ url: "https://img.youtube.com/large.jpg", width: 720, height: 404 },
|
||||
],
|
||||
keywords: ["music", "trending"],
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
// 3 search calls (one per keyword)
|
||||
mockFetch.mockResolvedValue(searchResult);
|
||||
|
||||
const items = await adapter.fetchTrending(20);
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items.length).toBeGreaterThanOrEqual(1);
|
||||
expect(items[0].id).toBe("abc123");
|
||||
expect(items[0].title).toBe("Test Video");
|
||||
expect(items[0].platform).toBe("youtube");
|
||||
expect(items[0].author_name).toBe("Test Channel");
|
||||
expect(items[0].play_count).toBe(1500000);
|
||||
expect(items[0].like_count).toBe(80000);
|
||||
expect(items[0].comment_count).toBe(3500);
|
||||
expect(items[0].cover_url).toBe("https://img.youtube.com/high.jpg");
|
||||
expect(items[0].cover_url).toBe("https://img.youtube.com/large.jpg");
|
||||
expect(items[0].tags).toEqual(["music", "trending"]);
|
||||
});
|
||||
|
||||
it("handles empty API response", async () => {
|
||||
mockFetch.mockResolvedValueOnce({});
|
||||
mockFetch.mockResolvedValue({});
|
||||
const items = await adapter.fetchTrending(20);
|
||||
expect(items).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses default values for missing fields", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
videos: [{}],
|
||||
});
|
||||
mockFetch.mockResolvedValue({ videos: [{ video_id: "x1" }] });
|
||||
|
||||
const items = await adapter.fetchTrending(20);
|
||||
expect(items[0].title).toBe("Untitled");
|
||||
@ -72,90 +64,85 @@ describe("YouTubeAdapter", () => {
|
||||
expect(items[0].play_count).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles id as object with videoId", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
it("deduplicates videos across search results", async () => {
|
||||
const searchResult = {
|
||||
videos: [
|
||||
{
|
||||
id: { videoId: "obj-id-123" },
|
||||
snippet: { title: "Object ID Video" },
|
||||
},
|
||||
{ video_id: "dup1", title: "Video 1", number_of_views: 100 },
|
||||
{ video_id: "dup1", title: "Video 1 dup", number_of_views: 100 },
|
||||
{ video_id: "dup2", title: "Video 2", number_of_views: 200 },
|
||||
],
|
||||
});
|
||||
};
|
||||
mockFetch.mockResolvedValue(searchResult);
|
||||
|
||||
const items = await adapter.fetchTrending(20);
|
||||
expect(items[0].id).toBe("obj-id-123");
|
||||
const ids = items.map((i) => i.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("parses string statistics correctly", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
it("sorts by play_count descending", async () => {
|
||||
const searchResult = {
|
||||
videos: [
|
||||
{
|
||||
video_id: "stat-test",
|
||||
statistics: {
|
||||
viewCount: "999",
|
||||
likeCount: "50",
|
||||
commentCount: "10",
|
||||
},
|
||||
},
|
||||
{ video_id: "low", title: "Low Views", number_of_views: 100 },
|
||||
{ video_id: "high", title: "High Views", number_of_views: 9999 },
|
||||
{ video_id: "mid", title: "Mid Views", number_of_views: 5000 },
|
||||
],
|
||||
});
|
||||
};
|
||||
mockFetch.mockResolvedValue(searchResult);
|
||||
|
||||
const items = await adapter.fetchTrending(20);
|
||||
expect(items[0].play_count).toBe(999);
|
||||
expect(items[0].like_count).toBe(50);
|
||||
expect(items[0].comment_count).toBe(10);
|
||||
});
|
||||
|
||||
it("prefers maxres thumbnail", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
videos: [
|
||||
{
|
||||
video_id: "thumb-test",
|
||||
snippet: {
|
||||
thumbnails: {
|
||||
maxres: { url: "https://img.youtube.com/maxres.jpg" },
|
||||
high: { url: "https://img.youtube.com/high.jpg" },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const items = await adapter.fetchTrending(20);
|
||||
expect(items[0].cover_url).toBe("https://img.youtube.com/maxres.jpg");
|
||||
// After dedup across 3 search calls, sorted by views
|
||||
expect(items[0].id).toBe("high");
|
||||
});
|
||||
|
||||
it("slices results to requested count", async () => {
|
||||
const ytItems = Array.from({ length: 30 }, (_, i) => ({
|
||||
video_id: `yt-${i}`,
|
||||
title: `Video ${i}`,
|
||||
number_of_views: 30 - i,
|
||||
}));
|
||||
mockFetch.mockResolvedValueOnce({ videos: ytItems });
|
||||
mockFetch.mockResolvedValue({ videos: ytItems });
|
||||
|
||||
const items = await adapter.fetchTrending(5);
|
||||
expect(items).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("picks the largest thumbnail", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
videos: [
|
||||
{
|
||||
video_id: "thumb-test",
|
||||
thumbnails: [
|
||||
{ url: "https://img.youtube.com/small.jpg", width: 360 },
|
||||
{ url: "https://img.youtube.com/large.jpg", width: 720 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const items = await adapter.fetchTrending(20);
|
||||
expect(items[0].cover_url).toBe("https://img.youtube.com/large.jpg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchDetail", () => {
|
||||
it("returns mapped ContentItem from video detail", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
id: "detail-456",
|
||||
snippet: {
|
||||
title: "Detail Video",
|
||||
channelTitle: "Detail Channel",
|
||||
publishedAt: "2024-02-01T12:00:00Z",
|
||||
thumbnails: {
|
||||
high: { url: "https://img.youtube.com/detail.jpg" },
|
||||
},
|
||||
},
|
||||
statistics: {
|
||||
viewCount: "50000",
|
||||
likeCount: "2000",
|
||||
},
|
||||
},
|
||||
id: "detail-456",
|
||||
title: "Detail Video",
|
||||
channel: {
|
||||
name: "Detail Channel",
|
||||
avatar: [
|
||||
{ url: "https://avatar.small.jpg", width: 48 },
|
||||
{ url: "https://avatar.large.jpg", width: 176 },
|
||||
],
|
||||
},
|
||||
viewCount: 50000,
|
||||
likeCount: 2000,
|
||||
commentCountText: "350",
|
||||
publishedTime: "2024-02-01T12:00:00Z",
|
||||
thumbnails: [
|
||||
{ url: "https://img.youtube.com/small.jpg", width: 168 },
|
||||
{ url: "https://img.youtube.com/detail.jpg", width: 720 },
|
||||
],
|
||||
});
|
||||
|
||||
@ -165,6 +152,11 @@ describe("YouTubeAdapter", () => {
|
||||
expect(item.title).toBe("Detail Video");
|
||||
expect(item.platform).toBe("youtube");
|
||||
expect(item.play_count).toBe(50000);
|
||||
expect(item.like_count).toBe(2000);
|
||||
expect(item.comment_count).toBe(350);
|
||||
expect(item.author_name).toBe("Detail Channel");
|
||||
expect(item.author_avatar).toBe("https://avatar.large.jpg");
|
||||
expect(item.cover_url).toBe("https://img.youtube.com/detail.jpg");
|
||||
});
|
||||
|
||||
it("handles missing detail data gracefully", async () => {
|
||||
|
||||
@ -3,78 +3,115 @@ import { tikhubFetch } from "../tikhub";
|
||||
|
||||
export class YouTubeAdapter implements PlatformAdapter {
|
||||
async fetchTrending(count: number): Promise<ContentItem[]> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = await tikhubFetch<any>(
|
||||
"/api/v1/youtube/web/get_trending_videos"
|
||||
// get_trending_videos endpoint is unreliable (returns empty),
|
||||
// use search_video with popular keywords as fallback (same approach as Twitter adapter)
|
||||
const searchKeywords = ["trending", "popular", "viral", "hot", "best"];
|
||||
const searchResults = await Promise.allSettled(
|
||||
searchKeywords.slice(0, 3).map((keyword) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
tikhubFetch<any>("/api/v1/youtube/web/search_video", {
|
||||
search_query: keyword,
|
||||
order_by: "this_week",
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Response: { videos: [...], number_of_videos, country, ... }
|
||||
const list = data?.videos || data?.items || [];
|
||||
const items = Array.isArray(list) ? list : [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const allVideos: any[] = [];
|
||||
for (const result of searchResults) {
|
||||
if (result.status !== "fulfilled") continue;
|
||||
const videos = result.value?.videos;
|
||||
if (Array.isArray(videos)) {
|
||||
allVideos.push(...videos);
|
||||
}
|
||||
}
|
||||
|
||||
return items
|
||||
.slice(0, count)
|
||||
.map((item: Record<string, unknown>, index: number) =>
|
||||
this.mapToContentItem(item, index)
|
||||
);
|
||||
// Deduplicate by video_id, sort by views, return top N
|
||||
const seen = new Set<string>();
|
||||
return allVideos
|
||||
.map((item, index) => this.mapSearchItem(item, index))
|
||||
.filter((item) => {
|
||||
if (seen.has(item.id)) return false;
|
||||
seen.add(item.id);
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => (b.play_count ?? 0) - (a.play_count ?? 0))
|
||||
.slice(0, count);
|
||||
}
|
||||
|
||||
async fetchDetail(id: string): Promise<ContentItem> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const data = await tikhubFetch<any>(
|
||||
"/api/v1/youtube/web/get_video_info",
|
||||
{ video_id: id }
|
||||
{ video_id: id, url_access: "blocked" }
|
||||
);
|
||||
|
||||
const videoData = data?.items?.[0] || data || {};
|
||||
return this.mapToContentItem(videoData, 0);
|
||||
return this.mapDetailItem(data || {});
|
||||
}
|
||||
|
||||
/** Map a video from the search_video endpoint */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private mapToContentItem(raw: any, index: number): ContentItem {
|
||||
// get_trending_videos format: { video_id, title, channel, views, ... }
|
||||
// get_video_info / YouTube Data API format: { id, snippet: {...}, statistics: {...} }
|
||||
const snippet = raw?.snippet || {};
|
||||
const stats = raw?.statistics || {};
|
||||
|
||||
const videoId =
|
||||
raw?.video_id ||
|
||||
(typeof raw?.id === "string" ? raw.id : raw?.id?.videoId) ||
|
||||
raw?.videoId ||
|
||||
`yt-${index}`;
|
||||
|
||||
// Thumbnails: trending format uses direct fields, Data API uses snippet.thumbnails
|
||||
const thumbs = snippet?.thumbnails || raw?.thumbnails || {};
|
||||
const coverUrl =
|
||||
thumbs?.maxres?.url ||
|
||||
thumbs?.high?.url ||
|
||||
thumbs?.medium?.url ||
|
||||
thumbs?.default?.url ||
|
||||
raw?.thumbnail ||
|
||||
undefined;
|
||||
|
||||
// Views/likes: trending format may use direct number fields
|
||||
const viewCount = raw?.views ?? stats?.viewCount;
|
||||
const likeCount = raw?.likes ?? stats?.likeCount;
|
||||
const commentCount = stats?.commentCount;
|
||||
private mapSearchItem(raw: any, index: number): ContentItem {
|
||||
// search_video format: { video_id, title, author, number_of_views, thumbnails: [{url,width,height}], ... }
|
||||
const videoId = raw?.video_id || `yt-${index}`;
|
||||
const thumbnails = raw?.thumbnails;
|
||||
const coverUrl = Array.isArray(thumbnails)
|
||||
? (thumbnails[thumbnails.length - 1]?.url || thumbnails[0]?.url)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: String(videoId),
|
||||
title: raw?.title || snippet?.title || "Untitled",
|
||||
title: raw?.title || "Untitled",
|
||||
cover_url: coverUrl,
|
||||
video_url: `https://www.youtube.com/watch?v=${videoId}`,
|
||||
author_name:
|
||||
raw?.channel || snippet?.channelTitle || raw?.channelTitle || "Unknown",
|
||||
author_name: raw?.author || "Unknown",
|
||||
author_avatar: undefined,
|
||||
play_count: viewCount != null ? parseInt(String(viewCount), 10) || undefined : undefined,
|
||||
like_count: likeCount != null ? parseInt(String(likeCount), 10) || undefined : undefined,
|
||||
play_count: raw?.number_of_views ?? undefined,
|
||||
like_count: undefined,
|
||||
collect_count: undefined,
|
||||
comment_count: commentCount != null ? parseInt(String(commentCount), 10) || undefined : undefined,
|
||||
comment_count: undefined,
|
||||
share_count: undefined,
|
||||
publish_time: raw?.published_at || snippet?.publishedAt || raw?.publishedAt || new Date().toISOString(),
|
||||
publish_time: raw?.published_time || new Date().toISOString(),
|
||||
platform: "youtube",
|
||||
original_url: `https://www.youtube.com/watch?v=${videoId}`,
|
||||
tags: snippet?.tags || raw?.tags || undefined,
|
||||
tags: raw?.keywords?.length ? raw.keywords : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Map a video from the get_video_info detail endpoint */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private mapDetailItem(raw: any): ContentItem {
|
||||
// get_video_info format: { id, title, channel: {name, avatar: [{url}]}, viewCount, likeCount, thumbnails: [{url}], ... }
|
||||
const videoId = raw?.id || "unknown";
|
||||
const channel = raw?.channel || {};
|
||||
const thumbnails = raw?.thumbnails;
|
||||
const coverUrl = Array.isArray(thumbnails)
|
||||
? (thumbnails[thumbnails.length - 1]?.url || thumbnails[0]?.url)
|
||||
: undefined;
|
||||
const avatars = channel?.avatar;
|
||||
const authorAvatar = Array.isArray(avatars)
|
||||
? (avatars[avatars.length - 1]?.url || avatars[0]?.url)
|
||||
: undefined;
|
||||
const commentCount = raw?.commentCountText
|
||||
? parseInt(String(raw.commentCountText).replace(/[^0-9]/g, ""), 10) || undefined
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: String(videoId),
|
||||
title: raw?.title || "Untitled",
|
||||
cover_url: coverUrl,
|
||||
video_url: `https://www.youtube.com/watch?v=${videoId}`,
|
||||
author_name: channel?.name || "Unknown",
|
||||
author_avatar: authorAvatar,
|
||||
play_count: raw?.viewCount ?? undefined,
|
||||
like_count: raw?.likeCount ?? undefined,
|
||||
collect_count: undefined,
|
||||
comment_count: commentCount,
|
||||
share_count: undefined,
|
||||
publish_time: raw?.publishedTime || new Date().toISOString(),
|
||||
platform: "youtube",
|
||||
original_url: `https://www.youtube.com/watch?v=${videoId}`,
|
||||
tags: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user