Compare commits
4
Commits
286b73a287
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e4a6dd8ee | ||
|
|
65a42c9b5c | ||
|
|
95627e3924 | ||
|
|
ceadeca4eb |
@@ -159,6 +159,21 @@ describe("InstagramAdapter", () => {
|
|||||||
expect(items[0].id).toBe("high");
|
expect(items[0].id).toBe("high");
|
||||||
expect(items[1].id).toBe("mid");
|
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,16 @@ export class InstagramAdapter implements PlatformAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
return items
|
return items
|
||||||
.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) => item.like_count != null && item.like_count >= 100)
|
.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))
|
.sort((a, b) => (b.like_count ?? 0) - (a.like_count ?? 0))
|
||||||
.slice(0, count);
|
.slice(0, count);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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)) {
|
if (trends.length === 0) return [];
|
||||||
items = data.trends;
|
|
||||||
} else if (Array.isArray(data?.tweets)) {
|
// Step 2: search top tweets for a few trending topics in parallel
|
||||||
items = data.tweets;
|
const topicsToSearch = trends.slice(0, 5);
|
||||||
} else if (data?.timeline?.instructions) {
|
const searchResults = await Promise.allSettled(
|
||||||
const instructions = data.timeline.instructions;
|
topicsToSearch.map((keyword) =>
|
||||||
for (const inst of instructions) {
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const entries = inst?.entries || [];
|
tikhubFetch<any>(
|
||||||
for (const entry of entries) {
|
"/api/v1/twitter/web/fetch_search_timeline",
|
||||||
const tweet =
|
{ keyword, search_type: "Top" }
|
||||||
entry?.content?.itemContent?.tweet_results?.result?.legacy ||
|
)
|
||||||
entry?.content?.itemContent?.tweet_results?.result ||
|
)
|
||||||
null;
|
);
|
||||||
if (tweet) items.push(tweet);
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return items
|
// Step 4: deduplicate, sort by likes, return top N
|
||||||
.slice(0, count)
|
const seen = new Set<string>();
|
||||||
.map((item: unknown, index: number) =>
|
return allTweets
|
||||||
this.mapToContentItem(item as Record<string, unknown>, index)
|
.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,33 +88,58 @@ 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";
|
||||||
|
|
||||||
// For trend items (from fetch_trending: { name, description, context })
|
const userInfo = raw?.user_info || {};
|
||||||
if (raw?.name && !raw?.full_text && !raw?.text) {
|
|
||||||
return {
|
// media is { video: [...], photo: [...] } or array or null
|
||||||
id: String(tweetId || `tw-trend-${index}`),
|
const mediaObj = raw?.media || {};
|
||||||
title: raw.name,
|
const firstVideo = mediaObj?.video?.[0] || null;
|
||||||
cover_url: undefined,
|
const firstPhoto = mediaObj?.photo?.[0] || null;
|
||||||
video_url: undefined,
|
const coverUrl =
|
||||||
author_name: raw?.context || "Twitter Trending",
|
firstVideo?.media_url_https ||
|
||||||
author_avatar: undefined,
|
firstPhoto?.media_url_https ||
|
||||||
play_count: raw?.tweet_volume ?? undefined,
|
undefined;
|
||||||
like_count: undefined,
|
const videoUrl = firstVideo?.variants?.find(
|
||||||
collect_count: undefined,
|
(v: { content_type?: string }) => v.content_type === "video/mp4"
|
||||||
comment_count: undefined,
|
)?.url || undefined;
|
||||||
share_count: undefined,
|
|
||||||
publish_time: new Date().toISOString(),
|
const hashtags = raw?.entities?.hashtags;
|
||||||
platform: "twitter",
|
|
||||||
original_url: raw?.url || `https://twitter.com/search?q=${encodeURIComponent(raw.name)}`,
|
return {
|
||||||
tags: undefined,
|
id: String(tweetId),
|
||||||
};
|
title: text,
|
||||||
}
|
cover_url: coverUrl,
|
||||||
|
video_url: videoUrl,
|
||||||
|
author_name: userInfo?.name || raw?.screen_name || "Unknown",
|
||||||
|
author_avatar: userInfo?.profile_image_url_https || undefined,
|
||||||
|
play_count: raw?.views ? parseInt(raw.views, 10) : undefined,
|
||||||
|
like_count: raw?.favorites ?? undefined,
|
||||||
|
collect_count: raw?.bookmarks ?? undefined,
|
||||||
|
comment_count: raw?.replies ?? undefined,
|
||||||
|
share_count: raw?.retweets ?? undefined,
|
||||||
|
publish_time: raw?.created_at
|
||||||
|
? parseTwitterDate(raw.created_at)
|
||||||
|
: new Date().toISOString(),
|
||||||
|
platform: "twitter",
|
||||||
|
original_url: `https://twitter.com/i/status/${tweetId}`,
|
||||||
|
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",
|
author: "Test Channel",
|
||||||
channelTitle: "Test Channel",
|
number_of_views: 1500000,
|
||||||
publishedAt: "2024-01-15T08:00:00Z",
|
published_time: "2 days ago",
|
||||||
thumbnails: {
|
thumbnails: [
|
||||||
high: { url: "https://img.youtube.com/high.jpg" },
|
{ url: "https://img.youtube.com/small.jpg", width: 360, height: 202 },
|
||||||
default: { url: "https://img.youtube.com/default.jpg" },
|
{ 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",
|
||||||
{
|
title: "Detail Video",
|
||||||
id: "detail-456",
|
channel: {
|
||||||
snippet: {
|
name: "Detail Channel",
|
||||||
title: "Detail Video",
|
avatar: [
|
||||||
channelTitle: "Detail Channel",
|
{ url: "https://avatar.small.jpg", width: 48 },
|
||||||
publishedAt: "2024-02-01T12:00:00Z",
|
{ url: "https://avatar.large.jpg", width: 176 },
|
||||||
thumbnails: {
|
],
|
||||||
high: { url: "https://img.youtube.com/detail.jpg" },
|
},
|
||||||
},
|
viewCount: 50000,
|
||||||
},
|
likeCount: 2000,
|
||||||
statistics: {
|
commentCountText: "350",
|
||||||
viewCount: "50000",
|
publishedTime: "2024-02-01T12:00:00Z",
|
||||||
likeCount: "2000",
|
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 () => {
|
||||||
|
|||||||
@@ -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[]> {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// get_trending_videos endpoint is unreliable (returns empty),
|
||||||
const data = await tikhubFetch<any>(
|
// use search_video with popular keywords as fallback (same approach as Twitter adapter)
|
||||||
"/api/v1/youtube/web/get_trending_videos"
|
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, ... }
|
// 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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user