Compare commits

...
6 Commits
Author SHA1 Message Date
wxsandClaude Opus 4.6 2e4a6dd8ee fix: YouTube 适配器改用搜索端点获取热门视频
get_trending_videos 端点当前返回空数据,改用 search_video 搜索热门关键词,
合并去重后按播放量排序。同时修复 fetchDetail 字段映射以匹配实际 API 响应格式。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 19:48:59 +08:00
wxsandClaude Opus 4.6 65a42c9b5c fix: 首页添加收藏和设置入口图标
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:33:52 +08:00
wxsandClaude Opus 4.6 95627e3924 fix: Twitter 适配器改用搜索端点获取热搜话题对应的真实推文
旧逻辑只返回热搜话题名称(无封面、无互动数据),现在改为:
1. 获取热搜话题列表
2. 取前 5 个话题并行搜索热门推文
3. 去重、按点赞排序后返回完整推文卡片

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:25:08 +08:00
wxsandClaude Opus 4.6 ceadeca4eb fix: Instagram 热点去除重复卡片
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:14:02 +08:00
wxsandClaude Opus 4.6 286b73a287 fix: Instagram 热点过滤低互动内容并按点赞数排序
- 适配器过滤 like_count < 100 的低质量内容,按点赞数降序排列
- 后端 dev/start 脚本添加 --env-file=.env 自动加载环境变量

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:11:40 +08:00
wxsandClaude Opus 4.6 e933c71b3d fix: 修复哔哩哔哩平台封面图无法显示的问题
- 后端 Bilibili 适配器添加 URL 规范化,处理协议相对路径和 http 协议
- 前端 Image 组件添加 referrerPolicy="no-referrer" 绕过 CDN 防盗链

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 15:55:06 +08:00
11 changed files with 423 additions and 293 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "tsx watch src/index.ts", "dev": "tsx watch --env-file=.env src/index.ts",
"start": "tsx src/index.ts", "start": "tsx --env-file=.env src/index.ts",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage" "test:coverage": "vitest run --coverage"
@@ -1,6 +1,13 @@
import type { ContentItem, PlatformAdapter } from "@muse/shared"; import type { ContentItem, PlatformAdapter } from "@muse/shared";
import { tikhubFetch } from "../tikhub"; import { tikhubFetch } from "../tikhub";
/** Ensure Bilibili URLs use https:// (API may return protocol-relative "//..." or http) */
function normalizeUrl(url: string): string {
if (url.startsWith("//")) return `https:${url}`;
if (url.startsWith("http://")) return url.replace("http://", "https://");
return url;
}
export class BilibiliAdapter implements PlatformAdapter { export class BilibiliAdapter implements PlatformAdapter {
async fetchTrending(count: number): Promise<ContentItem[]> { async fetchTrending(count: number): Promise<ContentItem[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -44,10 +51,10 @@ export class BilibiliAdapter implements PlatformAdapter {
return { return {
id: String(aid || bvid), id: String(aid || bvid),
title: raw?.title || "无标题", title: raw?.title || "无标题",
cover_url: raw?.pic || undefined, cover_url: raw?.pic ? normalizeUrl(raw.pic) : undefined,
video_url: undefined, video_url: undefined,
author_name: owner?.name || raw?.author || "未知作者", author_name: owner?.name || raw?.author || "未知作者",
author_avatar: owner?.face || undefined, author_avatar: owner?.face ? normalizeUrl(owner.face) : undefined,
play_count: stat?.view ?? undefined, play_count: stat?.view ?? undefined,
like_count: stat?.like ?? undefined, like_count: stat?.like ?? undefined,
collect_count: stat?.favorite ?? undefined, collect_count: stat?.favorite ?? undefined,
@@ -58,7 +58,7 @@ describe("InstagramAdapter", () => {
code: "SEC001", code: "SEC001",
caption: "Section post", caption: "Section post",
user: { username: "user1" }, user: { username: "user1" },
like_count: 1000, like_count: 5000,
}, },
], ],
}, },
@@ -87,6 +87,7 @@ describe("InstagramAdapter", () => {
code: "cap-obj", code: "cap-obj",
caption: { text: "Caption from object" }, caption: { text: "Caption from object" },
user: { username: "test" }, user: { username: "test" },
like_count: 500,
}, },
], ],
}); });
@@ -102,6 +103,7 @@ describe("InstagramAdapter", () => {
code: "cap-str", code: "cap-str",
caption: "String caption", caption: "String caption",
user: { username: "test" }, user: { username: "test" },
like_count: 500,
}, },
], ],
}); });
@@ -117,6 +119,7 @@ describe("InstagramAdapter", () => {
code: "cap-null", code: "cap-null",
caption: null, caption: null,
user: { username: "test" }, user: { username: "test" },
like_count: 500,
}, },
], ],
}); });
@@ -132,6 +135,7 @@ describe("InstagramAdapter", () => {
code: "thumb-test", code: "thumb-test",
thumbnail_url: "https://ig.com/thumb.jpg", thumbnail_url: "https://ig.com/thumb.jpg",
user: { username: "test" }, user: { username: "test" },
like_count: 500,
}, },
], ],
}); });
@@ -139,6 +143,37 @@ describe("InstagramAdapter", () => {
const items = await adapter.fetchTrending(20); const items = await adapter.fetchTrending(20);
expect(items[0].cover_url).toBe("https://ig.com/thumb.jpg"); expect(items[0].cover_url).toBe("https://ig.com/thumb.jpg");
}); });
it("filters out low-engagement items and sorts by likes", async () => {
mockFetch.mockResolvedValueOnce({
items: [
{ code: "low", caption: "Low", user: { username: "a" }, like_count: 5 },
{ code: "high", caption: "High", user: { username: "b" }, like_count: 10000 },
{ code: "mid", caption: "Mid", user: { username: "c" }, like_count: 500 },
{ code: "none", caption: "None", user: { username: "d" } },
],
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(2);
expect(items[0].id).toBe("high");
expect(items[1].id).toBe("mid");
});
it("deduplicates items by id", async () => {
mockFetch.mockResolvedValueOnce({
items: [
{ code: "AAA", caption: "First", user: { username: "a" }, like_count: 8000 },
{ code: "AAA", caption: "First dup", user: { username: "a" }, like_count: 8000 },
{ code: "BBB", caption: "Second", user: { username: "b" }, like_count: 3000 },
],
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(2);
expect(items[0].id).toBe("AAA");
expect(items[1].id).toBe("BBB");
});
}); });
describe("fetchDetail", () => { describe("fetchDetail", () => {
@@ -33,11 +33,18 @@ export class InstagramAdapter implements PlatformAdapter {
} }
} }
const seen = new Set<string>();
return items return items
.slice(0, count)
.map((item: unknown, index: number) => .map((item: unknown, index: number) =>
this.mapToContentItem(item as Record<string, unknown>, index) this.mapToContentItem(item as Record<string, unknown>, index)
); )
.filter((item) => {
if (seen.has(item.id)) return false;
seen.add(item.id);
return item.like_count != null && item.like_count >= 100;
})
.sort((a, b) => (b.like_count ?? 0) - (a.like_count ?? 0))
.slice(0, count);
} }
async fetchDetail(id: string): Promise<ContentItem> { async fetchDetail(id: string): Promise<ContentItem> {
+90 -105
View File
@@ -17,137 +17,122 @@ describe("TwitterAdapter", () => {
}); });
describe("fetchTrending", () => { describe("fetchTrending", () => {
it("returns mapped ContentItem[] from tweets format", async () => { it("fetches trending topics then searches tweets for each", async () => {
mockFetch.mockResolvedValueOnce({ // First call: fetch_trending returns topic names
tweets: [
{
id_str: "1234567890",
full_text: "This is a trending tweet!",
user: {
name: "Test User",
screen_name: "testuser",
profile_image_url_https: "https://pbs.twimg.com/avatar.jpg",
},
favorite_count: 5000,
retweet_count: 2000,
reply_count: 300,
created_at: "Mon Jan 15 08:00:00 +0000 2024",
entities: {
hashtags: [{ text: "trending" }, { text: "test" }],
},
},
],
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].id).toBe("1234567890");
expect(items[0].title).toBe("This is a trending tweet!");
expect(items[0].platform).toBe("twitter");
expect(items[0].author_name).toBe("Test User");
expect(items[0].like_count).toBe(5000);
expect(items[0].share_count).toBe(2000);
expect(items[0].tags).toEqual(["trending", "test"]);
});
it("returns mapped ContentItem[] from GraphQL format", async () => {
mockFetch.mockResolvedValueOnce({
timeline: {
instructions: [
{
entries: [
{
content: {
itemContent: {
tweet_results: {
result: {
legacy: {
id_str: "gql-001",
full_text: "GraphQL tweet",
user: { name: "GQL User" },
favorite_count: 100,
},
},
},
},
},
},
],
},
],
},
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].id).toBe("gql-001");
expect(items[0].title).toBe("GraphQL tweet");
});
it("returns mapped ContentItem[] from trends format", async () => {
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce({
trends: [ trends: [
{ name: "#Topic1" },
{ name: "#Topic2" },
],
});
// Second call: search for #Topic1
mockFetch.mockResolvedValueOnce({
timeline: [
{ {
name: "#TrendingTopic", type: "tweet",
tweet_volume: 50000, tweet_id: "100",
url: "https://twitter.com/search?q=%23TrendingTopic", text: "Tweet about Topic1",
screen_name: "user1",
favorites: 5000,
retweets: 1000,
replies: 50,
bookmarks: 20,
views: "80000",
created_at: "Mon Jan 15 08:00:00 +0000 2024",
entities: { hashtags: [{ text: "Topic1" }] },
user_info: { name: "User One", profile_image_url_https: "https://pbs.twimg.com/a.jpg" },
media: { photo: [{ media_url_https: "https://pbs.twimg.com/media/img.jpg" }] },
},
],
});
// Third call: search for #Topic2
mockFetch.mockResolvedValueOnce({
timeline: [
{
type: "tweet",
tweet_id: "200",
text: "Tweet about Topic2",
screen_name: "user2",
favorites: 3000,
retweets: 500,
user_info: { name: "User Two" },
}, },
], ],
}); });
const items = await adapter.fetchTrending(20); const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1); expect(items).toHaveLength(2);
expect(items[0].title).toBe("#TrendingTopic"); // Sorted by likes desc
expect(items[0].play_count).toBe(50000); expect(items[0].id).toBe("100");
expect(items[0].author_name).toBe("Twitter Trending"); expect(items[0].title).toBe("Tweet about Topic1");
expect(items[0].like_count).toBe(5000);
expect(items[0].share_count).toBe(1000);
expect(items[0].play_count).toBe(80000);
expect(items[0].cover_url).toBe("https://pbs.twimg.com/media/img.jpg");
expect(items[0].author_name).toBe("User One");
expect(items[0].tags).toEqual(["Topic1"]);
expect(items[1].id).toBe("200");
expect(items[1].like_count).toBe(3000);
}); });
it("handles empty API response", async () => { it("deduplicates tweets across topics", async () => {
mockFetch.mockResolvedValueOnce({
trends: [{ name: "#A" }, { name: "#B" }],
});
mockFetch.mockResolvedValueOnce({
timeline: [
{ type: "tweet", tweet_id: "111", text: "Same tweet", favorites: 1000, user_info: {} },
],
});
mockFetch.mockResolvedValueOnce({
timeline: [
{ type: "tweet", tweet_id: "111", text: "Same tweet", favorites: 1000, user_info: {} },
{ type: "tweet", tweet_id: "222", text: "Other tweet", favorites: 500, user_info: {} },
],
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(2);
expect(items[0].id).toBe("111");
expect(items[1].id).toBe("222");
});
it("handles empty trending response", async () => {
mockFetch.mockResolvedValueOnce({}); mockFetch.mockResolvedValueOnce({});
const items = await adapter.fetchTrending(20); const items = await adapter.fetchTrending(20);
expect(items).toEqual([]); expect(items).toEqual([]);
}); });
it("handles search failures gracefully", async () => {
mockFetch.mockResolvedValueOnce({
trends: [{ name: "#Fail" }],
});
mockFetch.mockRejectedValueOnce(new Error("search failed"));
const items = await adapter.fetchTrending(20);
expect(items).toEqual([]);
});
it("strips HTML from tweet text", async () => { it("strips HTML from tweet text", async () => {
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce({
tweets: [ trends: [{ name: "#HTML" }],
{
id_str: "html-001",
full_text: "Check out <a href='https://t.co/test'>this link</a>!",
user: { name: "HTML User" },
},
],
}); });
const items = await adapter.fetchTrending(20);
expect(items[0].title).toBe("Check out this link!");
});
it("extracts media from extended_entities", async () => {
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce({
tweets: [ timeline: [
{ {
id_str: "media-001", type: "tweet",
full_text: "Tweet with media", tweet_id: "300",
user: { name: "Media User" }, text: "Check <a href='url'>this</a>!",
extended_entities: { favorites: 100,
media: [ user_info: {},
{
media_url_https: "https://pbs.twimg.com/media/test.jpg",
video_info: { variants: [{ url: "https://video.twimg.com/test.mp4" }] },
},
],
},
}, },
], ],
}); });
const items = await adapter.fetchTrending(20); const items = await adapter.fetchTrending(20);
expect(items[0].cover_url).toBe("https://pbs.twimg.com/media/test.jpg"); expect(items[0].title).toBe("Check this!");
expect(items[0].video_url).toBe("https://video.twimg.com/test.mp4");
}); });
}); });
+90 -46
View File
@@ -13,46 +13,65 @@ function parseTwitterDate(dateStr: string): string {
export class TwitterAdapter implements PlatformAdapter { export class TwitterAdapter implements PlatformAdapter {
async fetchTrending(count: number): Promise<ContentItem[]> { async fetchTrending(count: number): Promise<ContentItem[]> {
// Step 1: get trending topic names
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>( const trendData = await tikhubFetch<any>(
"/api/v1/twitter/web/fetch_trending" "/api/v1/twitter/web/fetch_trending"
); );
// Response formats: const trends: string[] = [];
// 1. { trends: [{ name, description, context }] } — trending topics if (Array.isArray(trendData?.trends)) {
// 2. { tweets: [...] } — tweet objects for (const t of trendData.trends) {
// 3. GraphQL timeline.instructions[].entries[] if (t?.name) trends.push(t.name);
let items: unknown[] = [];
if (Array.isArray(data?.trends)) {
items = data.trends;
} else if (Array.isArray(data?.tweets)) {
items = data.tweets;
} else if (data?.timeline?.instructions) {
const instructions = data.timeline.instructions;
for (const inst of instructions) {
const entries = inst?.entries || [];
for (const entry of entries) {
const tweet =
entry?.content?.itemContent?.tweet_results?.result?.legacy ||
entry?.content?.itemContent?.tweet_results?.result ||
null;
if (tweet) items.push(tweet);
}
} }
} }
return items if (trends.length === 0) return [];
.slice(0, count)
.map((item: unknown, index: number) => // Step 2: search top tweets for a few trending topics in parallel
this.mapToContentItem(item as Record<string, unknown>, index) const topicsToSearch = trends.slice(0, 5);
const searchResults = await Promise.allSettled(
topicsToSearch.map((keyword) =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tikhubFetch<any>(
"/api/v1/twitter/web/fetch_search_timeline",
{ keyword, search_type: "Top" }
)
)
); );
// Step 3: collect tweets from all search results
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const allTweets: any[] = [];
for (const result of searchResults) {
if (result.status !== "fulfilled") continue;
const timeline = result.value?.timeline;
if (Array.isArray(timeline)) {
for (const item of timeline) {
if (item?.type === "tweet" && item?.tweet_id) {
allTweets.push(item);
}
}
}
}
// Step 4: deduplicate, sort by likes, return top N
const seen = new Set<string>();
return allTweets
.map((tweet, index) => this.mapSearchTweet(tweet, index))
.filter((item) => {
if (seen.has(item.id)) return false;
seen.add(item.id);
return true;
})
.sort((a, b) => (b.like_count ?? 0) - (a.like_count ?? 0))
.slice(0, count);
} }
async fetchDetail(id: string): Promise<ContentItem> { async fetchDetail(id: string): Promise<ContentItem> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>( const data = await tikhubFetch<any>(
"/api/v1/twitter/web/fetch_tweet_detail", "/api/v1/twitter/web/fetch_post_detail",
{ tweet_id: id } { tweet_id: id }
); );
@@ -69,34 +88,59 @@ export class TwitterAdapter implements PlatformAdapter {
data?.tweet?.core?.user_results?.result?.legacy || data?.tweet?.core?.user_results?.result?.legacy ||
null; null;
return this.mapToContentItem(tweetData, 0, userResult); return this.mapLegacyTweet(tweetData, 0, userResult);
} }
/** Map a tweet from the search_timeline endpoint */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapToContentItem(raw: any, index: number, userOverride?: any): ContentItem { private mapSearchTweet(raw: any, index: number): ContentItem {
const tweetId = raw?.id_str || raw?.rest_id || raw?.id || `tw-${index}`; const tweetId = raw?.tweet_id || `tw-${index}`;
const text = stripHtml(raw?.text || "").slice(0, 200) || "Untitled";
const userInfo = raw?.user_info || {};
// media is { video: [...], photo: [...] } or array or null
const mediaObj = raw?.media || {};
const firstVideo = mediaObj?.video?.[0] || null;
const firstPhoto = mediaObj?.photo?.[0] || null;
const coverUrl =
firstVideo?.media_url_https ||
firstPhoto?.media_url_https ||
undefined;
const videoUrl = firstVideo?.variants?.find(
(v: { content_type?: string }) => v.content_type === "video/mp4"
)?.url || undefined;
const hashtags = raw?.entities?.hashtags;
// For trend items (from fetch_trending: { name, description, context })
if (raw?.name && !raw?.full_text && !raw?.text) {
return { return {
id: String(tweetId || `tw-trend-${index}`), id: String(tweetId),
title: raw.name, title: text,
cover_url: undefined, cover_url: coverUrl,
video_url: undefined, video_url: videoUrl,
author_name: raw?.context || "Twitter Trending", author_name: userInfo?.name || raw?.screen_name || "Unknown",
author_avatar: undefined, author_avatar: userInfo?.profile_image_url_https || undefined,
play_count: raw?.tweet_volume ?? undefined, play_count: raw?.views ? parseInt(raw.views, 10) : undefined,
like_count: undefined, like_count: raw?.favorites ?? undefined,
collect_count: undefined, collect_count: raw?.bookmarks ?? undefined,
comment_count: undefined, comment_count: raw?.replies ?? undefined,
share_count: undefined, share_count: raw?.retweets ?? undefined,
publish_time: new Date().toISOString(), publish_time: raw?.created_at
? parseTwitterDate(raw.created_at)
: new Date().toISOString(),
platform: "twitter", platform: "twitter",
original_url: raw?.url || `https://twitter.com/search?q=${encodeURIComponent(raw.name)}`, original_url: `https://twitter.com/i/status/${tweetId}`,
tags: undefined, tags: Array.isArray(hashtags)
? hashtags.map((h: { text?: string }) => h.text).filter(Boolean)
: undefined,
}; };
} }
/** Map a tweet from the legacy/GraphQL detail endpoint */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapLegacyTweet(raw: any, index: number, userOverride?: any): ContentItem {
const tweetId = raw?.id_str || raw?.rest_id || raw?.id || `tw-${index}`;
const text = raw?.full_text || raw?.text || ""; const text = raw?.full_text || raw?.text || "";
const title = stripHtml(text).slice(0, 200) || "Untitled"; const title = stripHtml(text).slice(0, 200) || "Untitled";
@@ -17,54 +17,46 @@ describe("YouTubeAdapter", () => {
}); });
describe("fetchTrending", () => { describe("fetchTrending", () => {
it("returns mapped ContentItem[] from trending videos", async () => { it("returns mapped ContentItem[] from search results", async () => {
mockFetch.mockResolvedValueOnce({ const searchResult = {
videos: [ videos: [
{ {
video_id: "abc123", video_id: "abc123",
snippet: {
title: "Test Video", title: "Test Video",
channelTitle: "Test Channel", author: "Test Channel",
publishedAt: "2024-01-15T08:00:00Z", number_of_views: 1500000,
thumbnails: { published_time: "2 days ago",
high: { url: "https://img.youtube.com/high.jpg" }, thumbnails: [
default: { url: "https://img.youtube.com/default.jpg" }, { url: "https://img.youtube.com/small.jpg", width: 360, height: 202 },
}, { url: "https://img.youtube.com/large.jpg", width: 720, height: 404 },
tags: ["music", "trending"], ],
}, keywords: ["music", "trending"],
statistics: {
viewCount: "1500000",
likeCount: "80000",
commentCount: "3500",
},
}, },
], ],
}); };
// 3 search calls (one per keyword)
mockFetch.mockResolvedValue(searchResult);
const items = await adapter.fetchTrending(20); const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1); expect(items.length).toBeGreaterThanOrEqual(1);
expect(items[0].id).toBe("abc123"); expect(items[0].id).toBe("abc123");
expect(items[0].title).toBe("Test Video"); expect(items[0].title).toBe("Test Video");
expect(items[0].platform).toBe("youtube"); expect(items[0].platform).toBe("youtube");
expect(items[0].author_name).toBe("Test Channel"); expect(items[0].author_name).toBe("Test Channel");
expect(items[0].play_count).toBe(1500000); expect(items[0].play_count).toBe(1500000);
expect(items[0].like_count).toBe(80000); expect(items[0].cover_url).toBe("https://img.youtube.com/large.jpg");
expect(items[0].comment_count).toBe(3500);
expect(items[0].cover_url).toBe("https://img.youtube.com/high.jpg");
expect(items[0].tags).toEqual(["music", "trending"]); expect(items[0].tags).toEqual(["music", "trending"]);
}); });
it("handles empty API response", async () => { it("handles empty API response", async () => {
mockFetch.mockResolvedValueOnce({}); mockFetch.mockResolvedValue({});
const items = await adapter.fetchTrending(20); const items = await adapter.fetchTrending(20);
expect(items).toEqual([]); expect(items).toEqual([]);
}); });
it("uses default values for missing fields", async () => { it("uses default values for missing fields", async () => {
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValue({ videos: [{ video_id: "x1" }] });
videos: [{}],
});
const items = await adapter.fetchTrending(20); const items = await adapter.fetchTrending(20);
expect(items[0].title).toBe("Untitled"); expect(items[0].title).toBe("Untitled");
@@ -72,90 +64,85 @@ describe("YouTubeAdapter", () => {
expect(items[0].play_count).toBeUndefined(); expect(items[0].play_count).toBeUndefined();
}); });
it("handles id as object with videoId", async () => { it("deduplicates videos across search results", async () => {
mockFetch.mockResolvedValueOnce({ const searchResult = {
videos: [ videos: [
{ { video_id: "dup1", title: "Video 1", number_of_views: 100 },
id: { videoId: "obj-id-123" }, { video_id: "dup1", title: "Video 1 dup", number_of_views: 100 },
snippet: { title: "Object ID Video" }, { video_id: "dup2", title: "Video 2", number_of_views: 200 },
},
], ],
}); };
mockFetch.mockResolvedValue(searchResult);
const items = await adapter.fetchTrending(20); 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 () => { it("sorts by play_count descending", async () => {
mockFetch.mockResolvedValueOnce({ const searchResult = {
videos: [ videos: [
{ { video_id: "low", title: "Low Views", number_of_views: 100 },
video_id: "stat-test", { video_id: "high", title: "High Views", number_of_views: 9999 },
statistics: { { video_id: "mid", title: "Mid Views", number_of_views: 5000 },
viewCount: "999",
likeCount: "50",
commentCount: "10",
},
},
], ],
}); };
mockFetch.mockResolvedValue(searchResult);
const items = await adapter.fetchTrending(20); const items = await adapter.fetchTrending(20);
expect(items[0].play_count).toBe(999); // After dedup across 3 search calls, sorted by views
expect(items[0].like_count).toBe(50); expect(items[0].id).toBe("high");
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");
}); });
it("slices results to requested count", async () => { it("slices results to requested count", async () => {
const ytItems = Array.from({ length: 30 }, (_, i) => ({ const ytItems = Array.from({ length: 30 }, (_, i) => ({
video_id: `yt-${i}`, video_id: `yt-${i}`,
title: `Video ${i}`, title: `Video ${i}`,
number_of_views: 30 - i,
})); }));
mockFetch.mockResolvedValueOnce({ videos: ytItems }); mockFetch.mockResolvedValue({ videos: ytItems });
const items = await adapter.fetchTrending(5); const items = await adapter.fetchTrending(5);
expect(items).toHaveLength(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", () => { describe("fetchDetail", () => {
it("returns mapped ContentItem from video detail", async () => { it("returns mapped ContentItem from video detail", async () => {
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce({
items: [
{
id: "detail-456", id: "detail-456",
snippet: {
title: "Detail Video", title: "Detail Video",
channelTitle: "Detail Channel", channel: {
publishedAt: "2024-02-01T12:00:00Z", name: "Detail Channel",
thumbnails: { avatar: [
high: { url: "https://img.youtube.com/detail.jpg" }, { url: "https://avatar.small.jpg", width: 48 },
}, { url: "https://avatar.large.jpg", width: 176 },
}, ],
statistics: {
viewCount: "50000",
likeCount: "2000",
},
}, },
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.title).toBe("Detail Video");
expect(item.platform).toBe("youtube"); expect(item.platform).toBe("youtube");
expect(item.play_count).toBe(50000); 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 () => { it("handles missing detail data gracefully", async () => {
+84 -47
View File
@@ -3,78 +3,115 @@ import { tikhubFetch } from "../tikhub";
export class YouTubeAdapter implements PlatformAdapter { export class YouTubeAdapter implements PlatformAdapter {
async fetchTrending(count: number): Promise<ContentItem[]> { async fetchTrending(count: number): Promise<ContentItem[]> {
// 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>( tikhubFetch<any>("/api/v1/youtube/web/search_video", {
"/api/v1/youtube/web/get_trending_videos" search_query: keyword,
order_by: "this_week",
})
)
); );
// Response: { videos: [...], number_of_videos, country, ... } // eslint-disable-next-line @typescript-eslint/no-explicit-any
const list = data?.videos || data?.items || []; const allVideos: any[] = [];
const items = Array.isArray(list) ? list : []; for (const result of searchResults) {
if (result.status !== "fulfilled") continue;
const videos = result.value?.videos;
if (Array.isArray(videos)) {
allVideos.push(...videos);
}
}
return items // Deduplicate by video_id, sort by views, return top N
.slice(0, count) const seen = new Set<string>();
.map((item: Record<string, unknown>, index: number) => return allVideos
this.mapToContentItem(item, index) .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> { async fetchDetail(id: string): Promise<ContentItem> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>( const data = await tikhubFetch<any>(
"/api/v1/youtube/web/get_video_info", "/api/v1/youtube/web/get_video_info",
{ video_id: id } { video_id: id, url_access: "blocked" }
); );
const videoData = data?.items?.[0] || data || {}; return this.mapDetailItem(data || {});
return this.mapToContentItem(videoData, 0);
} }
/** Map a video from the search_video endpoint */
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapToContentItem(raw: any, index: number): ContentItem { private mapSearchItem(raw: any, index: number): ContentItem {
// get_trending_videos format: { video_id, title, channel, views, ... } // search_video format: { video_id, title, author, number_of_views, thumbnails: [{url,width,height}], ... }
// get_video_info / YouTube Data API format: { id, snippet: {...}, statistics: {...} } const videoId = raw?.video_id || `yt-${index}`;
const snippet = raw?.snippet || {}; const thumbnails = raw?.thumbnails;
const stats = raw?.statistics || {}; const coverUrl = Array.isArray(thumbnails)
? (thumbnails[thumbnails.length - 1]?.url || thumbnails[0]?.url)
const videoId = : undefined;
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;
return { return {
id: String(videoId), id: String(videoId),
title: raw?.title || snippet?.title || "Untitled", title: raw?.title || "Untitled",
cover_url: coverUrl, cover_url: coverUrl,
video_url: `https://www.youtube.com/watch?v=${videoId}`, video_url: `https://www.youtube.com/watch?v=${videoId}`,
author_name: author_name: raw?.author || "Unknown",
raw?.channel || snippet?.channelTitle || raw?.channelTitle || "Unknown",
author_avatar: undefined, author_avatar: undefined,
play_count: viewCount != null ? parseInt(String(viewCount), 10) || undefined : undefined, play_count: raw?.number_of_views ?? undefined,
like_count: likeCount != null ? parseInt(String(likeCount), 10) || undefined : undefined, like_count: undefined,
collect_count: undefined, collect_count: undefined,
comment_count: commentCount != null ? parseInt(String(commentCount), 10) || undefined : undefined, comment_count: undefined,
share_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", platform: "youtube",
original_url: `https://www.youtube.com/watch?v=${videoId}`, 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,
}; };
} }
} }
+19
View File
@@ -1,6 +1,8 @@
"use client"; "use client";
import { useState, useMemo, useCallback } from "react"; import { useState, useMemo, useCallback } from "react";
import Link from "next/link";
import { Heart, Settings } from "lucide-react";
import { useContentQuery, useRefreshContent } from "@/hooks/useContentQuery"; import { useContentQuery, useRefreshContent } from "@/hooks/useContentQuery";
import { PlatformTabs } from "@/components/layout/PlatformTabs"; import { PlatformTabs } from "@/components/layout/PlatformTabs";
import { SortToolbar, type SortField, type SortOrder } from "@/components/layout/SortToolbar"; import { SortToolbar, type SortField, type SortOrder } from "@/components/layout/SortToolbar";
@@ -58,6 +60,23 @@ export default function Home() {
return ( return (
<div className="px-4 py-4"> <div className="px-4 py-4">
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-bold text-slate-800">Muse</h1>
<div className="flex items-center gap-2">
<Link
href="/favorites"
className="inline-flex items-center justify-center w-9 h-9 rounded-md text-slate-500 hover:text-slate-800 hover:bg-slate-100 transition-colors"
>
<Heart className="w-5 h-5" />
</Link>
<Link
href="/settings"
className="inline-flex items-center justify-center w-9 h-9 rounded-md text-slate-500 hover:text-slate-800 hover:bg-slate-100 transition-colors"
>
<Settings className="w-5 h-5" />
</Link>
</div>
</div>
<PlatformTabs active={platform} onChange={setPlatform} /> <PlatformTabs active={platform} onChange={setPlatform} />
<SortToolbar <SortToolbar
sortBy={sortBy} sortBy={sortBy}
@@ -35,6 +35,7 @@ export function ContentCard({ item }: ContentCardProps) {
alt={item.title} alt={item.title}
fill fill
unoptimized unoptimized
referrerPolicy="no-referrer"
className="object-cover" className="object-cover"
loading="lazy" loading="lazy"
sizes="(max-width: 640px) 100vw, (max-width: 960px) 50vw, (max-width: 1240px) 33vw, 25vw" sizes="(max-width: 640px) 100vw, (max-width: 960px) 50vw, (max-width: 1240px) 33vw, 25vw"
@@ -84,6 +85,7 @@ export function ContentCard({ item }: ContentCardProps) {
width={20} width={20}
height={20} height={20}
unoptimized unoptimized
referrerPolicy="no-referrer"
className="rounded-full object-cover" className="rounded-full object-cover"
onError={(e) => { onError={(e) => {
(e.target as HTMLImageElement).style.display = "none"; (e.target as HTMLImageElement).style.display = "none";
@@ -46,6 +46,7 @@ export function DetailPanel({ item }: DetailPanelProps) {
alt={item.title} alt={item.title}
fill fill
unoptimized unoptimized
referrerPolicy="no-referrer"
className="object-cover" className="object-cover"
priority priority
sizes="(max-width: 768px) 100vw, 768px" sizes="(max-width: 768px) 100vw, 768px"
@@ -87,6 +88,7 @@ export function DetailPanel({ item }: DetailPanelProps) {
width={40} width={40}
height={40} height={40}
unoptimized unoptimized
referrerPolicy="no-referrer"
className="rounded-full object-cover" className="rounded-full object-cover"
onError={(e) => { onError={(e) => {
(e.target as HTMLImageElement).style.display = "none"; (e.target as HTMLImageElement).style.display = "none";