From 2e4a6dd8ee1407e65682bde7505c2b060e11d099 Mon Sep 17 00:00:00 2001 From: wxs Date: Tue, 3 Mar 2026 19:48:59 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20YouTube=20=E9=80=82=E9=85=8D=E5=99=A8?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E6=90=9C=E7=B4=A2=E7=AB=AF=E7=82=B9=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E7=83=AD=E9=97=A8=E8=A7=86=E9=A2=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_trending_videos 端点当前返回空数据,改用 search_video 搜索热门关键词, 合并去重后按播放量排序。同时修复 fetchDetail 字段映射以匹配实际 API 响应格式。 Co-Authored-By: Claude Opus 4.6 --- .../backend/src/lib/adapters/youtube.test.ts | 160 +++++++++--------- packages/backend/src/lib/adapters/youtube.ts | 133 +++++++++------ 2 files changed, 161 insertions(+), 132 deletions(-) diff --git a/packages/backend/src/lib/adapters/youtube.test.ts b/packages/backend/src/lib/adapters/youtube.test.ts index e00d5dc..ad18a11 100644 --- a/packages/backend/src/lib/adapters/youtube.test.ts +++ b/packages/backend/src/lib/adapters/youtube.test.ts @@ -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 () => { diff --git a/packages/backend/src/lib/adapters/youtube.ts b/packages/backend/src/lib/adapters/youtube.ts index 2dca56f..d30b20d 100644 --- a/packages/backend/src/lib/adapters/youtube.ts +++ b/packages/backend/src/lib/adapters/youtube.ts @@ -3,78 +3,115 @@ import { tikhubFetch } from "../tikhub"; export class YouTubeAdapter implements PlatformAdapter { async fetchTrending(count: number): Promise { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const data = await tikhubFetch( - "/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("/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, index: number) => - this.mapToContentItem(item, index) - ); + // Deduplicate by video_id, sort by views, return top N + const seen = new Set(); + 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 { // eslint-disable-next-line @typescript-eslint/no-explicit-any const data = await tikhubFetch( "/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, }; } }