feat: monorepo 重构 + 新增 5 个平台适配器

项目从单体结构重构为 pnpm monorepo (shared/backend/frontend),
新增 YouTube、Instagram、Twitter/X、哔哩哔哩、微博 5 个平台适配器,
包含完整的单元测试和 E2E 测试覆盖。

- 完成 T-031~T-044: 5 个适配器实现、注册、配置和测试
- 重构前后端分离: Hono 后端 + Next.js 前端
- 151 个单元测试 + 21 个 Mock E2E + 25 个真实 E2E
- 适配器基于真实 TikHub API 响应结构实现

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
wxs
2026-03-03 15:43:25 +08:00
co-authored by Claude Opus 4.6
parent ce736f197d
commit 6cc703ada2
136 changed files with 16805 additions and 520 deletions
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@muse/backend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@muse/shared": "workspace:*",
"hono": "^4.7.0",
"@hono/node-server": "^1.14.0"
},
"devDependencies": {
"typescript": "^5",
"tsx": "^4.19.0",
"vitest": "^4.0.18",
"@vitest/coverage-v8": "^4.0.18",
"@types/node": "^20"
}
}
+20
View File
@@ -0,0 +1,20 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import { tikhubRoutes } from "./routes/tikhub";
import { settingsRoutes } from "./routes/settings";
const app = new Hono();
app.use(
"*",
cors({
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
})
);
app.route("/api/tikhub", tikhubRoutes);
app.route("/api/settings", settingsRoutes);
app.get("/health", (c) => c.json({ status: "ok" }));
export { app };
+8
View File
@@ -0,0 +1,8 @@
import { serve } from "@hono/node-server";
import { app } from "./app";
const port = parseInt(process.env.PORT || "3001", 10);
serve({ fetch: app.fetch, port }, () => {
console.log(`🚀 Muse Backend running on http://localhost:${port}`);
});
@@ -0,0 +1,155 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { BilibiliAdapter } from "./bilibili";
vi.mock("../tikhub", () => ({
tikhubFetch: vi.fn(),
}));
import { tikhubFetch } from "../tikhub";
const mockFetch = vi.mocked(tikhubFetch);
describe("BilibiliAdapter", () => {
let adapter: BilibiliAdapter;
beforeEach(() => {
adapter = new BilibiliAdapter();
mockFetch.mockReset();
});
describe("fetchTrending", () => {
it("returns mapped ContentItem[] from popular video list", async () => {
// Bilibili double-wraps: tikhubFetch unwraps outer, inner is { code, data: { list } }
mockFetch.mockResolvedValueOnce({
code: 0,
message: "OK",
data: {
list: [
{
aid: 123456789,
bvid: "BV1xx411c7mD",
title: "B站热门视频",
pic: "https://i0.hdslb.com/cover.jpg",
owner: {
name: "UP主小明",
face: "https://i0.hdslb.com/avatar.jpg",
},
stat: {
aid: 123456789,
view: 1000000,
like: 50000,
favorite: 20000,
reply: 3000,
share: 5000,
},
pubdate: 1709424000,
tname: "科技",
},
],
},
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].id).toBe("123456789");
expect(items[0].title).toBe("B站热门视频");
expect(items[0].platform).toBe("bilibili");
expect(items[0].author_name).toBe("UP主小明");
expect(items[0].play_count).toBe(1000000);
expect(items[0].like_count).toBe(50000);
expect(items[0].collect_count).toBe(20000);
expect(items[0].comment_count).toBe(3000);
expect(items[0].share_count).toBe(5000);
expect(items[0].cover_url).toBe("https://i0.hdslb.com/cover.jpg");
expect(items[0].tags).toEqual(["科技"]);
expect(items[0].original_url).toBe("https://www.bilibili.com/video/BV1xx411c7mD");
});
it("handles empty API response", async () => {
mockFetch.mockResolvedValueOnce({});
const items = await adapter.fetchTrending(20);
expect(items).toEqual([]);
});
it("uses default values for missing fields", async () => {
mockFetch.mockResolvedValueOnce({
data: { list: [{}] },
});
const items = await adapter.fetchTrending(20);
expect(items[0].title).toBe("无标题");
expect(items[0].author_name).toBe("未知作者");
expect(items[0].play_count).toBeUndefined();
});
it("slices results to requested count", async () => {
const list = Array.from({ length: 30 }, (_, i) => ({
bvid: `BV${i}`,
title: `Video ${i}`,
}));
mockFetch.mockResolvedValueOnce({ data: { list } });
const items = await adapter.fetchTrending(5);
expect(items).toHaveLength(5);
});
it("maps aid as primary ID", async () => {
mockFetch.mockResolvedValueOnce({
data: {
list: [
{
aid: 999888,
bvid: "BV1abc",
title: "AID Test",
},
],
},
});
const items = await adapter.fetchTrending(20);
expect(items[0].id).toBe("999888");
});
});
describe("fetchDetail", () => {
it("returns mapped ContentItem from video detail", async () => {
// Detail response: { code, message, data: { View: {...} } }
mockFetch.mockResolvedValueOnce({
code: 0,
message: "OK",
data: {
View: {
aid: 111222333,
bvid: "BV1detail",
title: "详情视频",
pic: "https://i0.hdslb.com/detail.jpg",
owner: { name: "详情UP主", face: "https://face.jpg" },
stat: {
view: 500000,
like: 25000,
favorite: 10000,
reply: 1500,
share: 3000,
},
pubdate: 1709424000,
},
},
});
const item = await adapter.fetchDetail("111222333");
expect(item.id).toBe("111222333");
expect(item.title).toBe("详情视频");
expect(item.platform).toBe("bilibili");
expect(item.play_count).toBe(500000);
});
it("handles missing detail data gracefully", async () => {
mockFetch.mockResolvedValueOnce({});
const item = await adapter.fetchDetail("BV999");
expect(item.title).toBe("无标题");
expect(item.author_name).toBe("未知作者");
});
});
});
@@ -0,0 +1,66 @@
import type { ContentItem, PlatformAdapter } from "@muse/shared";
import { tikhubFetch } from "../tikhub";
export class BilibiliAdapter implements PlatformAdapter {
async fetchTrending(count: number): Promise<ContentItem[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/bilibili/web/fetch_com_popular"
);
// tikhubFetch unwraps outer { code, data } envelope, but Bilibili wraps again:
// data = { code: 0, message: "OK", data: { list: [...] } }
const inner = data?.data || data;
const list = inner?.list || data?.list || [];
const items = Array.isArray(list) ? list : [];
return items
.slice(0, count)
.map((item: Record<string, unknown>, index: number) =>
this.mapToContentItem(item, index)
);
}
async fetchDetail(id: string): Promise<ContentItem> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/bilibili/web/fetch_video_detail",
{ aid: id }
);
// Response: { code, message, data: { View: {...} } }
const inner = data?.data || data;
const videoData = inner?.View || inner || {};
return this.mapToContentItem(videoData, 0);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapToContentItem(raw: any, index: number): ContentItem {
const stat = raw?.stat || {};
const owner = raw?.owner || {};
const bvid = raw?.bvid || raw?.id || `bl-${index}`;
const aid = raw?.aid || stat?.aid || "";
return {
id: String(aid || bvid),
title: raw?.title || "无标题",
cover_url: raw?.pic || undefined,
video_url: undefined,
author_name: owner?.name || raw?.author || "未知作者",
author_avatar: owner?.face || undefined,
play_count: stat?.view ?? undefined,
like_count: stat?.like ?? undefined,
collect_count: stat?.favorite ?? undefined,
comment_count: stat?.reply ?? undefined,
share_count: stat?.share ?? undefined,
publish_time: raw?.pubdate
? new Date(raw.pubdate * 1000).toISOString()
: raw?.ctime
? new Date(raw.ctime * 1000).toISOString()
: new Date().toISOString(),
platform: "bilibili",
original_url: `https://www.bilibili.com/video/${bvid}`,
tags: raw?.tname ? [raw.tname] : undefined,
};
}
}
@@ -0,0 +1,129 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { DouyinAdapter } from "./douyin";
vi.mock("../tikhub", () => ({
tikhubFetch: vi.fn(),
}));
import { tikhubFetch } from "../tikhub";
const mockFetch = vi.mocked(tikhubFetch);
describe("DouyinAdapter", () => {
let adapter: DouyinAdapter;
beforeEach(() => {
adapter = new DouyinAdapter();
mockFetch.mockReset();
});
describe("fetchTrending", () => {
it("returns mapped ContentItem[] from hot video list", async () => {
mockFetch.mockResolvedValueOnce({
data: {
objs: [
{
item_id: "123",
item_title: "测试视频",
item_cover_url: "https://img.douyin.com/cover.jpg",
item_url: "https://douyin.com/video/123",
nick_name: "测试作者",
avatar_url: "https://img.douyin.com/avatar.jpg",
play_cnt: 10000,
like_cnt: 500,
publish_time: 1709424000,
},
],
},
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].id).toBe("123");
expect(items[0].title).toBe("测试视频");
expect(items[0].platform).toBe("douyin");
expect(items[0].author_name).toBe("测试作者");
expect(items[0].play_count).toBe(10000);
expect(items[0].like_count).toBe(500);
expect(items[0].cover_url).toBe("https://img.douyin.com/cover.jpg");
});
it("handles empty API response", async () => {
mockFetch.mockResolvedValueOnce({ data: {} });
const items = await adapter.fetchTrending(20);
expect(items).toEqual([]);
});
it("uses default values for missing fields", async () => {
mockFetch.mockResolvedValueOnce({
data: { objs: [{}] },
});
const items = await adapter.fetchTrending(20);
expect(items[0].title).toBe("无标题");
expect(items[0].author_name).toBe("未知作者");
expect(items[0].play_count).toBeUndefined();
});
it("slices results to requested count", async () => {
const objs = Array.from({ length: 30 }, (_, i) => ({
item_id: String(i),
item_title: `Video ${i}`,
}));
mockFetch.mockResolvedValueOnce({ data: { objs } });
const items = await adapter.fetchTrending(5);
expect(items).toHaveLength(5);
});
});
describe("fetchDetail", () => {
it("returns mapped ContentItem from video detail", async () => {
mockFetch.mockResolvedValueOnce({
aweme_detail: {
aweme_id: "456",
desc: "详情视频描述",
video: {
cover: { url_list: ["https://cover.jpg"] },
play_addr: { url_list: ["https://video.mp4"] },
},
author: {
nickname: "作者名",
avatar_thumb: { url_list: ["https://avatar.jpg"] },
},
statistics: {
play_count: 50000,
digg_count: 2000,
comment_count: 100,
share_count: 50,
collect_count: 300,
},
create_time: 1709424000,
share_url: "https://www.douyin.com/video/456",
text_extra: [{ hashtag_name: "热门" }, { hashtag_name: "创意" }],
},
});
const item = await adapter.fetchDetail("456");
expect(item.id).toBe("456");
expect(item.title).toBe("详情视频描述");
expect(item.cover_url).toBe("https://cover.jpg");
expect(item.video_url).toBe("https://video.mp4");
expect(item.author_name).toBe("作者名");
expect(item.play_count).toBe(50000);
expect(item.like_count).toBe(2000);
expect(item.tags).toEqual(["热门", "创意"]);
expect(item.platform).toBe("douyin");
});
it("handles missing detail data gracefully", async () => {
mockFetch.mockResolvedValueOnce({});
const item = await adapter.fetchDetail("999");
expect(item.id).toBe("unknown");
expect(item.title).toBe("无标题");
expect(item.author_name).toBe("未知作者");
});
});
});
@@ -0,0 +1,88 @@
import type { ContentItem, PlatformAdapter } from "@muse/shared";
import { tikhubFetch } from "../tikhub";
export class DouyinAdapter implements PlatformAdapter {
async fetchTrending(count: number): Promise<ContentItem[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/douyin/billboard/fetch_hot_total_video_list",
undefined,
"POST"
);
const list = data?.data?.objs || data?.data?.list || data?.data || [];
const items = Array.isArray(list) ? list : [];
return items.slice(0, count).map((item: Record<string, unknown>, index: number) =>
this.mapToContentItem(item, index)
);
}
async fetchDetail(id: string): Promise<ContentItem> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/douyin/web/fetch_one_video",
{ aweme_id: id }
);
const videoData = data?.aweme_detail || data?.data?.aweme_detail || data?.data || {};
return this.mapDetailItem(videoData);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapToContentItem(raw: any, index: number): ContentItem {
return {
id: String(raw?.item_id || `douyin-${index}`),
title: raw?.item_title || "无标题",
cover_url: raw?.item_cover_url || undefined,
video_url: raw?.item_url || undefined,
author_name: raw?.nick_name || "未知作者",
author_avatar: raw?.avatar_url || undefined,
play_count: raw?.play_cnt ?? undefined,
like_count: raw?.like_cnt ?? undefined,
collect_count: undefined,
comment_count: undefined,
share_count: undefined,
publish_time: raw?.publish_time
? new Date(raw.publish_time * 1000).toISOString()
: new Date().toISOString(),
platform: "douyin",
original_url: `https://www.douyin.com/video/${raw?.item_id || ""}`,
tags: undefined,
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapDetailItem(raw: any): ContentItem {
const stats = raw?.statistics || raw?.stats || {};
const author = raw?.author || {};
return {
id: String(raw?.aweme_id || raw?.id || "unknown"),
title: raw?.desc || raw?.title || "无标题",
cover_url:
raw?.video?.cover?.url_list?.[0] ||
raw?.video?.dynamic_cover?.url_list?.[0] ||
undefined,
video_url: raw?.video?.play_addr?.url_list?.[0] || undefined,
author_name: author?.nickname || "未知作者",
author_avatar: author?.avatar_thumb?.url_list?.[0] || undefined,
play_count: stats?.play_count ?? undefined,
like_count: stats?.digg_count ?? undefined,
collect_count: stats?.collect_count ?? undefined,
comment_count: stats?.comment_count ?? undefined,
share_count: stats?.share_count ?? undefined,
publish_time: raw?.create_time
? new Date(raw.create_time * 1000).toISOString()
: new Date().toISOString(),
platform: "douyin",
original_url:
raw?.share_url ||
`https://www.douyin.com/video/${raw?.aweme_id || raw?.id || ""}`,
tags:
raw?.text_extra
?.map((t: { hashtag_name?: string }) => t.hashtag_name)
.filter(Boolean) || undefined,
};
}
}
@@ -0,0 +1,61 @@
import { describe, it, expect, vi } from "vitest";
import { getAdapter, getSupportedPlatforms } from "./index";
describe("getAdapter", () => {
it("returns DouyinAdapter for douyin", () => {
const adapter = getAdapter("douyin");
expect(adapter).not.toBeNull();
expect(adapter!.fetchTrending).toBeDefined();
expect(adapter!.fetchDetail).toBeDefined();
});
it("returns TikTokAdapter for tiktok", () => {
const adapter = getAdapter("tiktok");
expect(adapter).not.toBeNull();
});
it("returns XiaohongshuAdapter for xiaohongshu", () => {
const adapter = getAdapter("xiaohongshu");
expect(adapter).not.toBeNull();
});
it("returns YouTubeAdapter for youtube", () => {
const adapter = getAdapter("youtube");
expect(adapter).not.toBeNull();
});
it("returns InstagramAdapter for instagram", () => {
const adapter = getAdapter("instagram");
expect(adapter).not.toBeNull();
});
it("returns TwitterAdapter for twitter", () => {
const adapter = getAdapter("twitter");
expect(adapter).not.toBeNull();
});
it("returns BilibiliAdapter for bilibili", () => {
const adapter = getAdapter("bilibili");
expect(adapter).not.toBeNull();
});
it("returns WeiboAdapter for weibo", () => {
const adapter = getAdapter("weibo");
expect(adapter).not.toBeNull();
});
});
describe("getSupportedPlatforms", () => {
it("returns all registered platforms", () => {
const platforms = getSupportedPlatforms();
expect(platforms).toContain("douyin");
expect(platforms).toContain("tiktok");
expect(platforms).toContain("xiaohongshu");
expect(platforms).toContain("youtube");
expect(platforms).toContain("instagram");
expect(platforms).toContain("twitter");
expect(platforms).toContain("bilibili");
expect(platforms).toContain("weibo");
expect(platforms).toHaveLength(8);
});
});
@@ -0,0 +1,33 @@
import type { Platform, PlatformAdapter } from "@muse/shared";
import { DouyinAdapter } from "./douyin";
import { TikTokAdapter } from "./tiktok";
import { XiaohongshuAdapter } from "./xiaohongshu";
import { YouTubeAdapter } from "./youtube";
import { InstagramAdapter } from "./instagram";
import { TwitterAdapter } from "./twitter";
import { BilibiliAdapter } from "./bilibili";
import { WeiboAdapter } from "./weibo";
const adapters: Partial<Record<Platform, PlatformAdapter>> = {
douyin: new DouyinAdapter(),
tiktok: new TikTokAdapter(),
xiaohongshu: new XiaohongshuAdapter(),
youtube: new YouTubeAdapter(),
instagram: new InstagramAdapter(),
twitter: new TwitterAdapter(),
bilibili: new BilibiliAdapter(),
weibo: new WeiboAdapter(),
};
export function getAdapter(platform: Platform): PlatformAdapter | null {
const adapter = adapters[platform];
if (!adapter) {
console.warn(`[adapters] 未找到平台适配器: ${platform}`);
return null;
}
return adapter;
}
export function getSupportedPlatforms(): Platform[] {
return Object.keys(adapters) as Platform[];
}
@@ -0,0 +1,175 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { InstagramAdapter } from "./instagram";
vi.mock("../tikhub", () => ({
tikhubFetch: vi.fn(),
}));
import { tikhubFetch } from "../tikhub";
const mockFetch = vi.mocked(tikhubFetch);
describe("InstagramAdapter", () => {
let adapter: InstagramAdapter;
beforeEach(() => {
adapter = new InstagramAdapter();
mockFetch.mockReset();
});
describe("fetchTrending", () => {
it("returns mapped ContentItem[] from flat items format", async () => {
mockFetch.mockResolvedValueOnce({
items: [
{
code: "ABC123",
caption: { text: "Beautiful sunset photo" },
image_versions2: {
candidates: [{ url: "https://ig.com/photo.jpg" }],
},
user: { username: "photographer", profile_pic_url: "https://ig.com/avatar.jpg" },
like_count: 5000,
comment_count: 200,
taken_at: 1709424000,
},
],
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].id).toBe("ABC123");
expect(items[0].title).toBe("Beautiful sunset photo");
expect(items[0].platform).toBe("instagram");
expect(items[0].author_name).toBe("photographer");
expect(items[0].like_count).toBe(5000);
expect(items[0].cover_url).toBe("https://ig.com/photo.jpg");
});
it("returns mapped ContentItem[] from sections format", async () => {
mockFetch.mockResolvedValueOnce({
sections: [
{
section_id: "123",
name: "Fashion",
subsections: [
{
medias: [
{
code: "SEC001",
caption: "Section post",
user: { username: "user1" },
like_count: 1000,
},
],
},
],
},
],
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].id).toBe("SEC001");
expect(items[0].title).toBe("Section post");
});
it("handles empty API response", async () => {
mockFetch.mockResolvedValueOnce({});
const items = await adapter.fetchTrending(20);
expect(items).toEqual([]);
});
it("handles caption as object with text", async () => {
mockFetch.mockResolvedValueOnce({
items: [
{
code: "cap-obj",
caption: { text: "Caption from object" },
user: { username: "test" },
},
],
});
const items = await adapter.fetchTrending(20);
expect(items[0].title).toBe("Caption from object");
});
it("handles caption as string", async () => {
mockFetch.mockResolvedValueOnce({
items: [
{
code: "cap-str",
caption: "String caption",
user: { username: "test" },
},
],
});
const items = await adapter.fetchTrending(20);
expect(items[0].title).toBe("String caption");
});
it("handles null caption", async () => {
mockFetch.mockResolvedValueOnce({
items: [
{
code: "cap-null",
caption: null,
user: { username: "test" },
},
],
});
const items = await adapter.fetchTrending(20);
expect(items[0].title).toBe("Untitled");
});
it("uses thumbnail_url as fallback cover", async () => {
mockFetch.mockResolvedValueOnce({
items: [
{
code: "thumb-test",
thumbnail_url: "https://ig.com/thumb.jpg",
user: { username: "test" },
},
],
});
const items = await adapter.fetchTrending(20);
expect(items[0].cover_url).toBe("https://ig.com/thumb.jpg");
});
});
describe("fetchDetail", () => {
it("returns mapped ContentItem from post detail", async () => {
mockFetch.mockResolvedValueOnce({
items: [
{
code: "detail-789",
caption: { text: "Detail post" },
user: { username: "detail_user", full_name: "Detail User" },
like_count: 10000,
comment_count: 500,
taken_at: 1709424000,
},
],
});
const item = await adapter.fetchDetail("detail-789");
expect(item.id).toBe("detail-789");
expect(item.title).toBe("Detail post");
expect(item.platform).toBe("instagram");
expect(item.like_count).toBe(10000);
});
it("handles missing detail data gracefully", async () => {
mockFetch.mockResolvedValueOnce({});
const item = await adapter.fetchDetail("999");
expect(item.title).toBe("Untitled");
expect(item.author_name).toBe("Unknown");
});
});
});
@@ -0,0 +1,96 @@
import type { ContentItem, PlatformAdapter } from "@muse/shared";
import { tikhubFetch } from "../tikhub";
export class InstagramAdapter implements PlatformAdapter {
async fetchTrending(count: number): Promise<ContentItem[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/instagram/v1/fetch_explore_sections"
);
// Response: { sections: [{ subsections: [{ medias: [...] }] }] }
// Or flat: { items: [...] } or { sectional_items: [...] }
let items: unknown[] = [];
if (Array.isArray(data?.sections)) {
for (const section of data.sections) {
const subsections = section?.subsections || [];
for (const sub of subsections) {
const medias = sub?.medias || [];
for (const m of medias) {
items.push(m?.media || m);
}
}
}
} else if (Array.isArray(data?.items)) {
items = data.items;
} else if (Array.isArray(data?.sectional_items)) {
for (const section of data.sectional_items) {
const medias = section?.layout_content?.medias || [];
for (const m of medias) {
items.push(m?.media || m);
}
}
}
return items
.slice(0, count)
.map((item: unknown, index: number) =>
this.mapToContentItem(item as Record<string, unknown>, index)
);
}
async fetchDetail(id: string): Promise<ContentItem> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/instagram/v2/fetch_post_info",
{ shortcode: id }
);
const postData = data?.items?.[0] || data?.data?.items?.[0] || data || {};
return this.mapToContentItem(postData, 0);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapToContentItem(raw: any, index: number): ContentItem {
const postId = raw?.code || raw?.shortcode || raw?.pk || raw?.id || `ig-${index}`;
// caption can be { text }, string, or null
let title = "";
const caption = raw?.caption;
if (caption && typeof caption === "object" && caption.text) {
title = caption.text;
} else if (typeof caption === "string") {
title = caption;
}
if (!title) title = raw?.title || "Untitled";
const coverUrl =
raw?.image_versions2?.candidates?.[0]?.url ||
raw?.thumbnail_url ||
raw?.display_url ||
undefined;
const user = raw?.user || raw?.owner || {};
return {
id: String(postId),
title: title.slice(0, 200),
cover_url: coverUrl,
video_url: raw?.video_url || undefined,
author_name: user?.username || user?.full_name || "Unknown",
author_avatar: user?.profile_pic_url || undefined,
play_count: raw?.video_view_count ?? raw?.play_count ?? undefined,
like_count: raw?.like_count ?? undefined,
collect_count: raw?.saved_count ?? undefined,
comment_count: raw?.comment_count ?? undefined,
share_count: raw?.reshare_count ?? undefined,
publish_time: raw?.taken_at
? new Date(raw.taken_at * 1000).toISOString()
: new Date().toISOString(),
platform: "instagram",
original_url: `https://www.instagram.com/p/${postId}/`,
tags: undefined,
};
}
}
@@ -0,0 +1,148 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TikTokAdapter } from "./tiktok";
vi.mock("../tikhub", () => ({
tikhubFetch: vi.fn(),
}));
import { tikhubFetch } from "../tikhub";
const mockFetch = vi.mocked(tikhubFetch);
describe("TikTokAdapter", () => {
let adapter: TikTokAdapter;
beforeEach(() => {
adapter = new TikTokAdapter();
mockFetch.mockReset();
});
describe("fetchTrending", () => {
it("returns mapped ContentItem[] from explore posts", async () => {
mockFetch.mockResolvedValueOnce({
itemList: [
{
id: "tt-123",
desc: "Trending video",
video: {
cover: "https://tiktok.com/cover.jpg",
playAddr: "https://tiktok.com/play.mp4",
},
author: {
nickname: "Creator",
uniqueId: "creator123",
avatarThumb: "https://tiktok.com/avatar.jpg",
},
stats: {
playCount: 100000,
diggCount: 5000,
commentCount: 200,
shareCount: 80,
collectCount: 150,
},
createTime: 1709424000,
},
],
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].id).toBe("tt-123");
expect(items[0].title).toBe("Trending video");
expect(items[0].platform).toBe("tiktok");
expect(items[0].author_name).toBe("Creator");
expect(items[0].play_count).toBe(100000);
expect(items[0].like_count).toBe(5000);
expect(items[0].cover_url).toBe("https://tiktok.com/cover.jpg");
});
it("handles empty response", async () => {
mockFetch.mockResolvedValueOnce({});
const items = await adapter.fetchTrending(20);
expect(items).toEqual([]);
});
it("handles alternative data shapes", async () => {
mockFetch.mockResolvedValueOnce({
data: {
itemList: [
{
id: "alt-1",
desc: "Alt format",
video: {},
author: {},
stats: {},
},
],
},
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].id).toBe("alt-1");
});
it("uses default values for missing fields", async () => {
mockFetch.mockResolvedValueOnce({
itemList: [{ video: {}, author: {}, stats: {} }],
});
const items = await adapter.fetchTrending(20);
expect(items[0].title).toBe("Untitled");
expect(items[0].author_name).toBe("Unknown");
});
it("slices results to count", async () => {
const itemList = Array.from({ length: 25 }, (_, i) => ({
id: String(i),
desc: `Video ${i}`,
video: {},
author: {},
stats: {},
}));
mockFetch.mockResolvedValueOnce({ itemList });
const items = await adapter.fetchTrending(10);
expect(items).toHaveLength(10);
});
});
describe("fetchDetail", () => {
it("returns mapped ContentItem from post detail", async () => {
mockFetch.mockResolvedValueOnce({
itemInfo: {
itemStruct: {
id: "detail-1",
desc: "Detail video",
video: { cover: "https://cover.jpg" },
author: { nickname: "Author" },
stats: { playCount: 999, diggCount: 100 },
createTime: 1709424000,
},
},
});
const item = await adapter.fetchDetail("detail-1");
expect(item.id).toBe("detail-1");
expect(item.title).toBe("Detail video");
expect(item.play_count).toBe(999);
});
it("extracts tags from challenges array", async () => {
mockFetch.mockResolvedValueOnce({
itemInfo: {
itemStruct: {
id: "tag-1",
video: {},
author: {},
stats: {},
challenges: [{ title: "trending" }, { title: "viral" }],
},
},
});
const item = await adapter.fetchDetail("tag-1");
expect(item.tags).toEqual(["trending", "viral"]);
});
});
});
@@ -0,0 +1,89 @@
import type { ContentItem, PlatformAdapter } from "@muse/shared";
import { tikhubFetch } from "../tikhub";
export class TikTokAdapter implements PlatformAdapter {
async fetchTrending(count: number): Promise<ContentItem[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/tiktok/web/fetch_explore_post"
);
const list =
data?.itemList ||
data?.data?.itemList ||
data?.items ||
[];
const items = Array.isArray(list) ? list : [];
return items.slice(0, count).map((item: Record<string, unknown>, index: number) =>
this.mapToContentItem(item, index)
);
}
async fetchDetail(id: string): Promise<ContentItem> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/tiktok/web/fetch_post_detail",
{ itemId: id }
);
const videoData =
data?.data?.aweme_detail || data?.itemInfo?.itemStruct || data?.data || data || {};
return this.mapToContentItem(videoData, 0);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapToContentItem(raw: any, index: number): ContentItem {
const stats = raw?.stats || raw?.statistics || {};
const author = raw?.author || {};
const video = raw?.video || {};
const coverUrl =
(typeof video?.cover === "string" ? video.cover : null) ||
video?.cover?.url_list?.[0] ||
video?.originCover ||
video?.dynamicCover ||
raw?.cover ||
undefined;
return {
id: String(raw?.id || raw?.aweme_id || `tiktok-${index}`),
title: raw?.desc || raw?.title || "Untitled",
cover_url: coverUrl,
video_url:
(typeof video?.playAddr === "string" ? video.playAddr : null) ||
video?.playAddr?.url_list?.[0] ||
video?.play_addr?.url_list?.[0] ||
undefined,
author_name:
author?.nickname || author?.uniqueId || author?.unique_id || "Unknown",
author_avatar:
author?.avatarThumb || author?.avatarMedium ||
author?.avatar_thumb?.url_list?.[0] ||
undefined,
play_count: stats?.playCount ?? stats?.play_count ?? undefined,
like_count: stats?.diggCount ?? stats?.digg_count ?? undefined,
collect_count: stats?.collectCount ?? stats?.collect_count ?? undefined,
comment_count: stats?.commentCount ?? stats?.comment_count ?? undefined,
share_count: stats?.shareCount ?? stats?.share_count ?? undefined,
publish_time: raw?.createTime
? new Date(raw.createTime * 1000).toISOString()
: raw?.create_time
? new Date(raw.create_time * 1000).toISOString()
: new Date().toISOString(),
platform: "tiktok",
original_url:
raw?.share_url ||
`https://www.tiktok.com/@${author?.uniqueId || author?.unique_id || "user"}/video/${raw?.id || raw?.aweme_id || ""}`,
tags:
raw?.textExtra
?.map((t: { hashtagName?: string; hashtag_name?: string }) => t.hashtagName || t.hashtag_name)
.filter(Boolean) ||
raw?.challenges
?.map((c: { title?: string }) => c.title)
.filter(Boolean) ||
undefined,
};
}
}
@@ -0,0 +1,213 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { TwitterAdapter } from "./twitter";
vi.mock("../tikhub", () => ({
tikhubFetch: vi.fn(),
}));
import { tikhubFetch } from "../tikhub";
const mockFetch = vi.mocked(tikhubFetch);
describe("TwitterAdapter", () => {
let adapter: TwitterAdapter;
beforeEach(() => {
adapter = new TwitterAdapter();
mockFetch.mockReset();
});
describe("fetchTrending", () => {
it("returns mapped ContentItem[] from tweets format", async () => {
mockFetch.mockResolvedValueOnce({
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({
trends: [
{
name: "#TrendingTopic",
tweet_volume: 50000,
url: "https://twitter.com/search?q=%23TrendingTopic",
},
],
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].title).toBe("#TrendingTopic");
expect(items[0].play_count).toBe(50000);
expect(items[0].author_name).toBe("Twitter Trending");
});
it("handles empty API response", async () => {
mockFetch.mockResolvedValueOnce({});
const items = await adapter.fetchTrending(20);
expect(items).toEqual([]);
});
it("strips HTML from tweet text", async () => {
mockFetch.mockResolvedValueOnce({
tweets: [
{
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({
tweets: [
{
id_str: "media-001",
full_text: "Tweet with media",
user: { name: "Media User" },
extended_entities: {
media: [
{
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);
expect(items[0].cover_url).toBe("https://pbs.twimg.com/media/test.jpg");
expect(items[0].video_url).toBe("https://video.twimg.com/test.mp4");
});
});
describe("fetchDetail", () => {
it("returns mapped ContentItem from GraphQL detail format", async () => {
mockFetch.mockResolvedValueOnce({
tweetResult: {
result: {
legacy: {
id_str: "detail-001",
full_text: "Detail tweet content",
favorite_count: 1000,
retweet_count: 500,
created_at: "Wed Feb 01 12:00:00 +0000 2024",
},
core: {
user_results: {
result: {
legacy: {
name: "Detail Author",
screen_name: "detailauthor",
},
},
},
},
},
},
});
const item = await adapter.fetchDetail("detail-001");
expect(item.id).toBe("detail-001");
expect(item.title).toBe("Detail tweet content");
expect(item.author_name).toBe("Detail Author");
expect(item.like_count).toBe(1000);
});
it("returns mapped ContentItem from direct tweet format", async () => {
mockFetch.mockResolvedValueOnce({
tweet: {
legacy: {
id_str: "direct-001",
full_text: "Direct tweet",
user: { name: "Direct User" },
favorite_count: 200,
},
},
});
const item = await adapter.fetchDetail("direct-001");
expect(item.id).toBe("direct-001");
expect(item.title).toBe("Direct tweet");
});
it("handles missing detail data gracefully", async () => {
mockFetch.mockResolvedValueOnce({});
const item = await adapter.fetchDetail("999");
expect(item.title).toBe("Untitled");
});
});
});
@@ -0,0 +1,133 @@
import type { ContentItem, PlatformAdapter } from "@muse/shared";
import { tikhubFetch } from "../tikhub";
function stripHtml(text: string): string {
return text.replace(/<[^>]*>/g, "").trim();
}
function parseTwitterDate(dateStr: string): string {
// Twitter format: "Mon Jan 01 00:00:00 +0000 2024"
const d = new Date(dateStr);
return isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString();
}
export class TwitterAdapter implements PlatformAdapter {
async fetchTrending(count: number): Promise<ContentItem[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/twitter/web/fetch_trending"
);
// Response formats:
// 1. { trends: [{ name, description, context }] } — trending topics
// 2. { tweets: [...] } — tweet objects
// 3. GraphQL timeline.instructions[].entries[]
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
.slice(0, count)
.map((item: unknown, index: number) =>
this.mapToContentItem(item as Record<string, unknown>, index)
);
}
async fetchDetail(id: string): Promise<ContentItem> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/twitter/web/fetch_tweet_detail",
{ tweet_id: id }
);
// Two formats: GraphQL tweetResult.result or direct tweet object
const tweetData =
data?.tweetResult?.result?.legacy ||
data?.tweetResult?.result ||
data?.tweet?.legacy ||
data?.tweet ||
data || {};
const userResult =
data?.tweetResult?.result?.core?.user_results?.result?.legacy ||
data?.tweet?.core?.user_results?.result?.legacy ||
null;
return this.mapToContentItem(tweetData, 0, userResult);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapToContentItem(raw: any, index: number, userOverride?: any): ContentItem {
const tweetId = raw?.id_str || raw?.rest_id || raw?.id || `tw-${index}`;
// For trend items (from fetch_trending: { name, description, context })
if (raw?.name && !raw?.full_text && !raw?.text) {
return {
id: String(tweetId || `tw-trend-${index}`),
title: raw.name,
cover_url: undefined,
video_url: undefined,
author_name: raw?.context || "Twitter Trending",
author_avatar: undefined,
play_count: raw?.tweet_volume ?? undefined,
like_count: undefined,
collect_count: undefined,
comment_count: undefined,
share_count: undefined,
publish_time: new Date().toISOString(),
platform: "twitter",
original_url: raw?.url || `https://twitter.com/search?q=${encodeURIComponent(raw.name)}`,
tags: undefined,
};
}
const text = raw?.full_text || raw?.text || "";
const title = stripHtml(text).slice(0, 200) || "Untitled";
const user = userOverride || raw?.user || {};
const media =
raw?.extended_entities?.media?.[0] ||
raw?.entities?.media?.[0] ||
null;
const coverUrl = media?.media_url_https || media?.media_url || undefined;
return {
id: String(tweetId),
title,
cover_url: coverUrl,
video_url: media?.video_info?.variants?.[0]?.url || undefined,
author_name: user?.name || user?.screen_name || "Unknown",
author_avatar: user?.profile_image_url_https || undefined,
play_count: undefined,
like_count: raw?.favorite_count ?? undefined,
collect_count: raw?.bookmark_count ?? undefined,
comment_count: raw?.reply_count ?? undefined,
share_count: raw?.retweet_count ?? undefined,
publish_time: raw?.created_at
? parseTwitterDate(raw.created_at)
: new Date().toISOString(),
platform: "twitter",
original_url: `https://twitter.com/i/status/${tweetId}`,
tags: raw?.entities?.hashtags
?.map((h: { text?: string }) => h.text)
.filter(Boolean) || undefined,
};
}
}
@@ -0,0 +1,167 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { WeiboAdapter } from "./weibo";
vi.mock("../tikhub", () => ({
tikhubFetch: vi.fn(),
}));
import { tikhubFetch } from "../tikhub";
const mockFetch = vi.mocked(tikhubFetch);
describe("WeiboAdapter", () => {
let adapter: WeiboAdapter;
beforeEach(() => {
adapter = new WeiboAdapter();
mockFetch.mockReset();
});
describe("fetchTrending", () => {
it("returns mapped ContentItem[] from statuses format", async () => {
mockFetch.mockResolvedValueOnce({
statuses: [
{
mid: "5012345678",
text: "今天天气真好 <a href='https://t.cn/test'>链接</a>",
user: {
screen_name: "微博用户",
id: "1234567",
profile_image_url: "https://tvax.sinaimg.cn/avatar.jpg",
},
attitudes_count: 5000,
comments_count: 1200,
reposts_count: 800,
created_at: "Mon Jan 15 08:00:00 +0800 2024",
pic_infos: {
"pic001": {
large: { url: "https://ww1.sinaimg.cn/large/pic001.jpg" },
},
},
},
],
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].id).toBe("5012345678");
expect(items[0].title).toBe("今天天气真好 链接");
expect(items[0].platform).toBe("weibo");
expect(items[0].author_name).toBe("微博用户");
expect(items[0].like_count).toBe(5000);
expect(items[0].comment_count).toBe(1200);
expect(items[0].share_count).toBe(800);
expect(items[0].cover_url).toBe("https://ww1.sinaimg.cn/large/pic001.jpg");
});
it("returns mapped ContentItem[] from band_list format", async () => {
mockFetch.mockResolvedValueOnce({
band_list: [
{
word: "热搜话题一",
num: 1500000,
category: "社会",
realpos: 1,
},
],
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].title).toBe("热搜话题一");
expect(items[0].play_count).toBe(1500000);
expect(items[0].author_name).toBe("微博热搜");
expect(items[0].tags).toEqual(["社会"]);
});
it("returns mapped ContentItem[] from realtime format", async () => {
mockFetch.mockResolvedValueOnce({
realtime: [
{
word: "实时热搜",
raw_hot: 2000000,
realpos: 2,
},
],
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].title).toBe("实时热搜");
expect(items[0].play_count).toBe(2000000);
});
it("handles empty API response", async () => {
mockFetch.mockResolvedValueOnce({});
const items = await adapter.fetchTrending(20);
expect(items).toEqual([]);
});
it("strips HTML from weibo text", async () => {
mockFetch.mockResolvedValueOnce({
statuses: [
{
mid: "html-001",
text: "<b>粗体</b>和<a href='#'>链接</a>文字",
user: { screen_name: "测试" },
},
],
});
const items = await adapter.fetchTrending(20);
expect(items[0].title).toBe("粗体和链接文字");
});
it("constructs image URL from pic_ids when pic_infos missing", async () => {
mockFetch.mockResolvedValueOnce({
statuses: [
{
mid: "picid-001",
text: "有图微博",
user: { screen_name: "test" },
pic_ids: ["abc123def"],
},
],
});
const items = await adapter.fetchTrending(20);
expect(items[0].cover_url).toBe("https://ww1.sinaimg.cn/large/abc123def.jpg");
});
});
describe("fetchDetail", () => {
it("returns mapped ContentItem from post detail", async () => {
mockFetch.mockResolvedValueOnce({
data: {
mid: "detail-001",
text: "详情微博内容",
user: {
screen_name: "详情作者",
id: "9876543",
},
attitudes_count: 10000,
comments_count: 2000,
reposts_count: 1500,
created_at: "Wed Feb 01 12:00:00 +0800 2024",
},
});
const item = await adapter.fetchDetail("detail-001");
expect(item.id).toBe("detail-001");
expect(item.title).toBe("详情微博内容");
expect(item.platform).toBe("weibo");
expect(item.like_count).toBe(10000);
});
it("handles missing detail data gracefully", async () => {
mockFetch.mockResolvedValueOnce({});
const item = await adapter.fetchDetail("999");
expect(item.title).toBe("无标题");
expect(item.author_name).toBe("未知作者");
});
});
});
+152
View File
@@ -0,0 +1,152 @@
import type { ContentItem, PlatformAdapter } from "@muse/shared";
import { tikhubFetch } from "../tikhub";
function stripHtml(text: string): string {
return text.replace(/<[^>]*>/g, "").trim();
}
function parseWeiboDate(dateStr: string): string {
// Weibo format: "Mon Jan 01 00:00:00 +0800 2024"
const d = new Date(dateStr);
return isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString();
}
export class WeiboAdapter implements PlatformAdapter {
async fetchTrending(count: number): Promise<ContentItem[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/weibo/app/fetch_hot_search"
);
// Response formats:
// 1. { items: [{ type: "vertical", category: "group", items: [{ data: { desc, ... } }] }] }
// — App hot search with nested card structure
// 2. { statuses: [...] } — weibo posts
// 3. { band_list: [...] } or { realtime: [...] } — flat hot topics
if (Array.isArray(data?.statuses)) {
return data.statuses
.slice(0, count)
.map((item: unknown, index: number) =>
this.mapStatusToContentItem(item as Record<string, unknown>, index)
);
}
// App nested card format: items[] > group items > card data with desc
if (Array.isArray(data?.items)) {
const topics: unknown[] = [];
for (const item of data.items) {
if (item?.type === "vertical" && item?.category === "group") {
const innerItems = item?.items || [];
for (const inner of innerItems) {
const cardData = inner?.data;
if (cardData?.desc) {
topics.push(cardData);
}
}
}
}
if (topics.length > 0) {
return topics
.slice(0, count)
.map((item: unknown, index: number) =>
this.mapTopicToContentItem(item as Record<string, unknown>, index)
);
}
}
const topics = data?.band_list || data?.realtime || [];
if (Array.isArray(topics) && topics.length > 0) {
return topics
.slice(0, count)
.map((item: unknown, index: number) =>
this.mapTopicToContentItem(item as Record<string, unknown>, index)
);
}
return [];
}
async fetchDetail(id: string): Promise<ContentItem> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/weibo/app/fetch_status_detail",
{ status_id: id }
);
const postData = data?.data || data || {};
return this.mapStatusToContentItem(postData, 0);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapStatusToContentItem(raw: any, index: number): ContentItem {
const user = raw?.user || {};
const postId = raw?.mid || raw?.id || raw?.idstr || `wb-${index}`;
const text = raw?.text || raw?.text_raw || "";
const title = stripHtml(text).slice(0, 200) || "无标题";
// Extract image URL from pic_infos or pic_ids
let coverUrl: string | undefined;
if (raw?.pic_infos) {
const firstPicId = Object.keys(raw.pic_infos)[0];
if (firstPicId) {
coverUrl = raw.pic_infos[firstPicId]?.large?.url || raw.pic_infos[firstPicId]?.original?.url;
}
}
if (!coverUrl && raw?.pic_ids?.[0]) {
coverUrl = `https://ww1.sinaimg.cn/large/${raw.pic_ids[0]}.jpg`;
}
if (!coverUrl) {
coverUrl = raw?.thumbnail_pic || undefined;
}
return {
id: String(postId),
title,
cover_url: coverUrl,
video_url: raw?.page_info?.media_info?.mp4_720p_mp4 || undefined,
author_name: user?.screen_name || user?.name || "未知作者",
author_avatar: user?.profile_image_url || user?.avatar_large || undefined,
play_count: raw?.reads_count ?? raw?.page_info?.play_count ?? undefined,
like_count: raw?.attitudes_count ?? undefined,
collect_count: undefined,
comment_count: raw?.comments_count ?? undefined,
share_count: raw?.reposts_count ?? undefined,
publish_time: raw?.created_at
? parseWeiboDate(raw.created_at)
: new Date().toISOString(),
platform: "weibo",
original_url: `https://weibo.com/${user?.id || "u"}/${postId}`,
tags: undefined,
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapTopicToContentItem(raw: any, index: number): ContentItem {
// App card format: { desc, scheme, pic, icon, ... }
// Band list format: { word, num, category, ... }
const word = raw?.desc || raw?.word || raw?.note || raw?.query || "";
const category = raw?.category || raw?.label_name || "";
return {
id: String(raw?.mid || raw?.realpos || index),
title: word || "热搜话题",
cover_url: raw?.pic || raw?.icon?.url || undefined,
video_url: undefined,
author_name: "微博热搜",
author_avatar: undefined,
play_count: raw?.num || raw?.raw_hot || undefined,
like_count: undefined,
collect_count: undefined,
comment_count: undefined,
share_count: undefined,
publish_time: new Date().toISOString(),
platform: "weibo",
original_url: raw?.scheme
? `https://s.weibo.com/weibo?q=${encodeURIComponent(word)}`
: `https://s.weibo.com/weibo?q=${encodeURIComponent(word)}`,
tags: category ? [category] : undefined,
};
}
}
@@ -0,0 +1,153 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { XiaohongshuAdapter } from "./xiaohongshu";
vi.mock("../tikhub", () => ({
tikhubFetch: vi.fn(),
}));
import { tikhubFetch } from "../tikhub";
const mockFetch = vi.mocked(tikhubFetch);
describe("XiaohongshuAdapter", () => {
let adapter: XiaohongshuAdapter;
beforeEach(() => {
adapter = new XiaohongshuAdapter();
mockFetch.mockReset();
});
describe("fetchTrending", () => {
it("returns mapped ContentItem[] from hot inspiration feed", async () => {
mockFetch.mockResolvedValueOnce({
data: {
items: [
{
hot_id: "xhs-001",
title: "热门话题测试",
cover: "https://xhs.com/cover.jpg",
score: 5000000,
score_text: "500万人在看",
type: "美妆",
},
],
},
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(1);
expect(items[0].id).toBe("xhs-001");
expect(items[0].title).toBe("热门话题测试");
expect(items[0].platform).toBe("xiaohongshu");
expect(items[0].cover_url).toBe("https://xhs.com/cover.jpg");
expect(items[0].play_count).toBe(5000000);
expect(items[0].tags).toEqual(["美妆"]);
});
it("parses score_text with 万 unit", async () => {
mockFetch.mockResolvedValueOnce({
data: {
items: [
{ hot_id: "1", title: "Topic", score_text: "1000万人在看" },
],
},
});
const items = await adapter.fetchTrending(20);
expect(items[0].play_count).toBe(10000000);
});
it("parses score_text with 亿 unit", async () => {
mockFetch.mockResolvedValueOnce({
data: {
items: [
{ hot_id: "2", title: "Topic", score_text: "1.5亿人在看" },
],
},
});
const items = await adapter.fetchTrending(20);
expect(items[0].play_count).toBe(150000000);
});
it("filters out items with title 无标题", async () => {
mockFetch.mockResolvedValueOnce({
data: {
items: [
{ hot_id: "1", title: "有标题" },
{ hot_id: "2" },
],
},
});
const items = await adapter.fetchTrending(20);
expect(items.every((i) => i.title !== "无标题")).toBe(true);
});
it("extracts title from deeplink when title is missing", async () => {
const deeplink = encodeURIComponent(
'{"content":"#春天穿搭[话题]"}'
);
mockFetch.mockResolvedValueOnce({
data: {
items: [{ hot_id: "3", deeplink }],
},
});
const items = await adapter.fetchTrending(20);
expect(items[0].title).toBe("春天穿搭");
});
it("handles empty response", async () => {
mockFetch.mockResolvedValueOnce({});
const items = await adapter.fetchTrending(20);
expect(items).toEqual([]);
});
});
describe("fetchDetail", () => {
it("returns mapped ContentItem from note detail", async () => {
mockFetch.mockResolvedValueOnce({
data: {
note_list: [
{
note_id: "note-123",
display_title: "笔记标题",
images_list: [{ url: "https://xhs.com/img.jpg" }],
user: {
nickname: "小红书博主",
avatar: "https://xhs.com/avatar.jpg",
},
interact_info: {
liked_count: 300,
collected_count: 50,
comment_count: 20,
share_count: 10,
},
time: 1709424000,
tag_list: [{ name: "穿搭" }, { name: "日常" }],
},
],
},
});
const item = await adapter.fetchDetail("note-123");
expect(item.id).toBe("note-123");
expect(item.title).toBe("笔记标题");
expect(item.author_name).toBe("小红书博主");
expect(item.like_count).toBe(300);
expect(item.collect_count).toBe(50);
expect(item.tags).toEqual(["穿搭", "日常"]);
expect(item.platform).toBe("xiaohongshu");
});
it("handles missing detail data", async () => {
mockFetch.mockResolvedValueOnce({});
const item = await adapter.fetchDetail("missing");
expect(item.title).toBe("无标题");
expect(item.author_name).toBe("未知作者");
});
});
});
@@ -0,0 +1,158 @@
import type { ContentItem, PlatformAdapter } from "@muse/shared";
import { tikhubFetch } from "../tikhub";
export class XiaohongshuAdapter implements PlatformAdapter {
async fetchTrending(count: number): Promise<ContentItem[]> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/xiaohongshu/app_v2/get_creator_hot_inspiration_feed",
{ cursor: "" }
);
const list =
data?.data?.items ||
data?.items ||
[];
const items = Array.isArray(list) ? list : [];
return items
.slice(0, count)
.map((item: Record<string, unknown>, index: number) =>
this.mapHotItemToContentItem(item, index)
)
.filter((item: ContentItem) => item.title !== "无标题");
}
async fetchDetail(id: string): Promise<ContentItem> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = await tikhubFetch<any>(
"/api/v1/xiaohongshu/app/get_note_info",
{ note_id: id }
);
const noteData =
data?.data?.note_list?.[0] ||
data?.data?.items?.[0]?.note ||
data?.data ||
data ||
{};
return this.mapNoteToContentItem(noteData, 0);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapHotItemToContentItem(raw: any, index: number): ContentItem {
let title = raw?.title || "";
if (!title && raw?.deeplink) {
try {
const decoded = decodeURIComponent(raw.deeplink);
const match = decoded.match(/"content":"([^"]+)"/);
if (match) {
title = match[1]
.trim()
.replace(/#/g, "")
.replace(/\[话题\]/g, "")
.trim();
}
} catch {
// ignore decode errors
}
}
if (!title) title = "无标题";
let viewCount: number | undefined;
const score = raw?.score;
const scoreText = raw?.score_text || "";
if (typeof score === "number" && score > 0) {
viewCount = score;
} else if (scoreText) {
const numMatch = scoreText.match(/([\d.]+)/);
if (numMatch) {
const num = parseFloat(numMatch[1]);
if (scoreText.includes("亿")) {
viewCount = Math.round(num * 100000000);
} else if (scoreText.includes("万")) {
viewCount = Math.round(num * 10000);
} else {
viewCount = Math.round(num);
}
}
}
const hotId = raw?.hot_id || raw?.id || "";
const coverUrl = raw?.cover || undefined;
return {
id: String(hotId || `xhs-${index}`),
title,
cover_url: coverUrl,
video_url: undefined,
author_name: "小红书热榜",
author_avatar: undefined,
play_count: viewCount,
like_count: undefined,
collect_count: undefined,
comment_count: undefined,
share_count: undefined,
publish_time: new Date().toISOString(),
platform: "xiaohongshu",
original_url: `https://www.xiaohongshu.com/search_result?keyword=${encodeURIComponent(title)}&type=51`,
tags: raw?.type ? [raw.type] : undefined,
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private mapNoteToContentItem(note: any, index: number): ContentItem {
const user = note?.user || {};
const interactInfo = note?.interact_info || {};
const images = note?.images_list || note?.image_list || [];
const coverFromImages = images[0]?.url || images[0]?.url_default || undefined;
const coverObj = note?.cover || {};
const coverFromCover =
(typeof coverObj === "string" ? coverObj : null) ||
coverObj?.url ||
coverObj?.url_default ||
coverObj?.url_pre ||
undefined;
return {
id: String(note?.note_id || note?.id || `xhs-${index}`),
title:
note?.display_title || note?.title || note?.desc?.slice(0, 60) || "无标题",
cover_url: coverFromImages || coverFromCover || undefined,
video_url: note?.video?.url || undefined,
author_name: user?.nickname || user?.name || "未知作者",
author_avatar: user?.avatar || user?.image || undefined,
play_count: undefined,
like_count:
interactInfo?.liked_count ??
note?.liked_count ??
note?.likes ??
undefined,
collect_count:
interactInfo?.collected_count ??
note?.collected_count ??
undefined,
comment_count:
interactInfo?.comment_count ??
note?.comment_count ??
undefined,
share_count:
interactInfo?.share_count ??
note?.share_count ??
undefined,
publish_time: note?.time
? new Date(note.time * 1000).toISOString()
: note?.timestamp
? new Date(note.timestamp * 1000).toISOString()
: new Date().toISOString(),
platform: "xiaohongshu",
original_url: `https://www.xiaohongshu.com/explore/${note?.note_id || note?.id || ""}`,
tags:
note?.tag_list
?.map((t: { name?: string }) => t.name)
.filter(Boolean) || undefined,
};
}
}
@@ -0,0 +1,178 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { YouTubeAdapter } from "./youtube";
vi.mock("../tikhub", () => ({
tikhubFetch: vi.fn(),
}));
import { tikhubFetch } from "../tikhub";
const mockFetch = vi.mocked(tikhubFetch);
describe("YouTubeAdapter", () => {
let adapter: YouTubeAdapter;
beforeEach(() => {
adapter = new YouTubeAdapter();
mockFetch.mockReset();
});
describe("fetchTrending", () => {
it("returns mapped ContentItem[] from trending videos", async () => {
mockFetch.mockResolvedValueOnce({
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",
},
},
],
});
const items = await adapter.fetchTrending(20);
expect(items).toHaveLength(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].tags).toEqual(["music", "trending"]);
});
it("handles empty API response", async () => {
mockFetch.mockResolvedValueOnce({});
const items = await adapter.fetchTrending(20);
expect(items).toEqual([]);
});
it("uses default values for missing fields", async () => {
mockFetch.mockResolvedValueOnce({
videos: [{}],
});
const items = await adapter.fetchTrending(20);
expect(items[0].title).toBe("Untitled");
expect(items[0].author_name).toBe("Unknown");
expect(items[0].play_count).toBeUndefined();
});
it("handles id as object with videoId", async () => {
mockFetch.mockResolvedValueOnce({
videos: [
{
id: { videoId: "obj-id-123" },
snippet: { title: "Object ID Video" },
},
],
});
const items = await adapter.fetchTrending(20);
expect(items[0].id).toBe("obj-id-123");
});
it("parses string statistics correctly", async () => {
mockFetch.mockResolvedValueOnce({
videos: [
{
video_id: "stat-test",
statistics: {
viewCount: "999",
likeCount: "50",
commentCount: "10",
},
},
],
});
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");
});
it("slices results to requested count", async () => {
const ytItems = Array.from({ length: 30 }, (_, i) => ({
video_id: `yt-${i}`,
title: `Video ${i}`,
}));
mockFetch.mockResolvedValueOnce({ videos: ytItems });
const items = await adapter.fetchTrending(5);
expect(items).toHaveLength(5);
});
});
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",
},
},
],
});
const item = await adapter.fetchDetail("detail-456");
expect(item.id).toBe("detail-456");
expect(item.title).toBe("Detail Video");
expect(item.platform).toBe("youtube");
expect(item.play_count).toBe(50000);
});
it("handles missing detail data gracefully", async () => {
mockFetch.mockResolvedValueOnce({});
const item = await adapter.fetchDetail("999");
expect(item.title).toBe("Untitled");
expect(item.author_name).toBe("Unknown");
});
});
});
@@ -0,0 +1,80 @@
import type { ContentItem, PlatformAdapter } from "@muse/shared";
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"
);
// Response: { videos: [...], number_of_videos, country, ... }
const list = data?.videos || data?.items || [];
const items = Array.isArray(list) ? list : [];
return items
.slice(0, count)
.map((item: Record<string, unknown>, index: number) =>
this.mapToContentItem(item, index)
);
}
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 }
);
const videoData = data?.items?.[0] || data || {};
return this.mapToContentItem(videoData, 0);
}
// 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;
return {
id: String(videoId),
title: raw?.title || snippet?.title || "Untitled",
cover_url: coverUrl,
video_url: `https://www.youtube.com/watch?v=${videoId}`,
author_name:
raw?.channel || snippet?.channelTitle || raw?.channelTitle || "Unknown",
author_avatar: undefined,
play_count: viewCount != null ? parseInt(String(viewCount), 10) || undefined : undefined,
like_count: likeCount != null ? parseInt(String(likeCount), 10) || undefined : undefined,
collect_count: undefined,
comment_count: commentCount != null ? parseInt(String(commentCount), 10) || undefined : undefined,
share_count: undefined,
publish_time: raw?.published_at || snippet?.publishedAt || raw?.publishedAt || new Date().toISOString(),
platform: "youtube",
original_url: `https://www.youtube.com/watch?v=${videoId}`,
tags: snippet?.tags || raw?.tags || undefined,
};
}
}
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
let canMakeRequest: () => boolean;
let recordRequest: () => void;
let waitForSlot: () => Promise<void>;
beforeEach(async () => {
vi.useFakeTimers();
vi.resetModules();
const mod = await import("./rate-limiter");
canMakeRequest = mod.canMakeRequest;
recordRequest = mod.recordRequest;
waitForSlot = mod.waitForSlot;
});
afterEach(() => {
vi.useRealTimers();
});
describe("canMakeRequest", () => {
it("returns true when no requests have been made", () => {
expect(canMakeRequest()).toBe(true);
});
it("returns true for fewer than 10 requests in window", () => {
for (let i = 0; i < 9; i++) {
recordRequest();
}
expect(canMakeRequest()).toBe(true);
});
it("returns false when 10 requests made within 1 second", () => {
for (let i = 0; i < 10; i++) {
recordRequest();
}
expect(canMakeRequest()).toBe(false);
});
it("returns true after window expires", () => {
for (let i = 0; i < 10; i++) {
recordRequest();
}
expect(canMakeRequest()).toBe(false);
vi.advanceTimersByTime(1001);
expect(canMakeRequest()).toBe(true);
});
});
describe("recordRequest", () => {
it("records a request timestamp", () => {
expect(canMakeRequest()).toBe(true);
for (let i = 0; i < 10; i++) {
recordRequest();
}
expect(canMakeRequest()).toBe(false);
});
});
describe("waitForSlot", () => {
it("resolves immediately when a slot is available", async () => {
await waitForSlot();
expect(true).toBe(true);
});
it("waits until a slot opens when at capacity", async () => {
for (let i = 0; i < 9; i++) {
recordRequest();
}
await waitForSlot();
expect(canMakeRequest()).toBe(false);
const promise = waitForSlot();
vi.advanceTimersByTime(1100);
await promise;
expect(true).toBe(true);
});
});
+24
View File
@@ -0,0 +1,24 @@
const WINDOW_MS = 1000;
const MAX_REQUESTS = 10;
const timestamps: number[] = [];
export function canMakeRequest(): boolean {
const now = Date.now();
// Remove timestamps outside the window
while (timestamps.length > 0 && timestamps[0] <= now - WINDOW_MS) {
timestamps.shift();
}
return timestamps.length < MAX_REQUESTS;
}
export function recordRequest(): void {
timestamps.push(Date.now());
}
export async function waitForSlot(): Promise<void> {
while (!canMakeRequest()) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
recordRequest();
}
+166
View File
@@ -0,0 +1,166 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
TikHubError,
getApiKey,
setRuntimeApiKey,
tikhubFetch,
} from "./tikhub";
vi.mock("./rate-limiter", () => ({
waitForSlot: vi.fn().mockResolvedValue(undefined),
}));
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
describe("TikHubError", () => {
it("creates error with statusCode and message", () => {
const err = new TikHubError(401, "Unauthorized");
expect(err.statusCode).toBe(401);
expect(err.message).toBe("Unauthorized");
expect(err.name).toBe("TikHubError");
expect(err).toBeInstanceOf(Error);
});
});
describe("getApiKey / setRuntimeApiKey", () => {
const originalEnv = process.env.TIKHUB_API_KEY;
afterEach(() => {
setRuntimeApiKey("");
if (originalEnv !== undefined) {
process.env.TIKHUB_API_KEY = originalEnv;
} else {
delete process.env.TIKHUB_API_KEY;
}
});
it("returns null when no key is configured", () => {
setRuntimeApiKey("");
delete process.env.TIKHUB_API_KEY;
expect(getApiKey()).toBeNull();
});
it("returns env variable when set", () => {
process.env.TIKHUB_API_KEY = "env-key-123";
setRuntimeApiKey("");
expect(getApiKey()).toBe("env-key-123");
});
it("returns runtime key with priority over env", () => {
process.env.TIKHUB_API_KEY = "env-key-123";
setRuntimeApiKey("runtime-key-456");
expect(getApiKey()).toBe("runtime-key-456");
});
});
describe("tikhubFetch", () => {
beforeEach(() => {
mockFetch.mockReset();
setRuntimeApiKey("test-api-key");
});
afterEach(() => {
setRuntimeApiKey("");
});
it("throws TikHubError 401 when no API key", async () => {
setRuntimeApiKey("");
delete process.env.TIKHUB_API_KEY;
await expect(tikhubFetch("/test")).rejects.toThrow(TikHubError);
await expect(tikhubFetch("/test")).rejects.toMatchObject({
statusCode: 401,
});
});
it("makes GET request with correct headers", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ result: "ok" }),
});
await tikhubFetch("/api/v1/test");
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/test"),
expect.objectContaining({
method: "GET",
headers: {
Authorization: "Bearer test-api-key",
"Content-Type": "application/json",
},
})
);
});
it("appends query params for GET requests", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: [] }),
});
await tikhubFetch("/api/v1/test", { foo: "bar", count: "20" });
const calledUrl = mockFetch.mock.calls[0][0];
expect(calledUrl).toContain("foo=bar");
expect(calledUrl).toContain("count=20");
});
it("makes POST request with body", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ code: 0, data: { items: [] } }),
});
await tikhubFetch("/api/v1/test", undefined, "POST", { key: "value" });
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: "POST",
body: JSON.stringify({ key: "value" }),
})
);
});
it("unwraps TikHub envelope { code, data }", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ code: 0, data: { items: [1, 2, 3] } }),
});
const result = await tikhubFetch("/api/v1/test");
expect(result).toEqual({ items: [1, 2, 3] });
});
it("returns raw json when no envelope", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ items: [1, 2, 3] }),
});
const result = await tikhubFetch("/api/v1/test");
expect(result).toEqual({ items: [1, 2, 3] });
});
it("throws TikHubError 401 on unauthorized response", async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 401 });
await expect(tikhubFetch("/test")).rejects.toMatchObject({
statusCode: 401,
});
});
it("throws TikHubError 429 on rate limit response", async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 429 });
await expect(tikhubFetch("/test")).rejects.toMatchObject({
statusCode: 429,
});
});
it("throws TikHubError on other HTTP errors", async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 500 });
await expect(tikhubFetch("/test")).rejects.toMatchObject({
statusCode: 500,
});
});
});
+71
View File
@@ -0,0 +1,71 @@
import { waitForSlot } from "./rate-limiter";
const TIKHUB_BASE_URL = "https://api.tikhub.io";
// Runtime API Key (set via POST /api/settings)
let runtimeApiKey: string | null = null;
export function setRuntimeApiKey(key: string) {
runtimeApiKey = key;
}
export function getApiKey(): string | null {
return runtimeApiKey || process.env.TIKHUB_API_KEY || null;
}
export async function tikhubFetch<T>(
endpoint: string,
params?: Record<string, string>,
method: "GET" | "POST" = "GET",
body?: Record<string, unknown>
): Promise<T> {
const apiKey = getApiKey();
if (!apiKey) {
throw new TikHubError(401, "API Key 未配置,请在设置页面配置 TikHub API Key");
}
await waitForSlot();
const url = new URL(endpoint, TIKHUB_BASE_URL);
if (params) {
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
}
const res = await fetch(url.toString(), {
method,
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
...(method === "POST" ? { body: JSON.stringify(body || {}) } : {}),
});
if (!res.ok) {
if (res.status === 401) {
throw new TikHubError(401, "API Key 无效,请检查配置");
}
if (res.status === 429) {
throw new TikHubError(429, "请求过于频繁,请稍后重试");
}
throw new TikHubError(res.status, `TikHub API 错误: ${res.status}`);
}
const json = await res.json();
// TikHub wraps all responses in { code, data, ... } envelope
// Unwrap to return the inner data directly
if (json?.code !== undefined && json?.data !== undefined) {
return json.data as T;
}
return json as T;
}
export class TikHubError extends Error {
constructor(
public statusCode: number,
message: string
) {
super(message);
this.name = "TikHubError";
}
}
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../lib/tikhub", () => ({
setRuntimeApiKey: vi.fn(),
getApiKey: vi.fn(),
}));
import { setRuntimeApiKey, getApiKey } from "../lib/tikhub";
import { app } from "../app";
const mockSetKey = vi.mocked(setRuntimeApiKey);
const mockGetKey = vi.mocked(getApiKey);
describe("POST /api/settings", () => {
beforeEach(() => {
mockSetKey.mockReset();
});
it("saves valid API key and returns success", async () => {
const res = await app.request("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: "test-key-123" }),
});
const data = await res.json();
expect(res.status).toBe(200);
expect(data.success).toBe(true);
expect(mockSetKey).toHaveBeenCalledWith("test-key-123");
});
it("trims whitespace from API key", async () => {
await app.request("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: " key-with-spaces " }),
});
expect(mockSetKey).toHaveBeenCalledWith("key-with-spaces");
});
it("returns 400 for empty API key", async () => {
const res = await app.request("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: "" }),
});
expect(res.status).toBe(400);
});
it("returns 400 for whitespace-only API key", async () => {
const res = await app.request("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: " " }),
});
expect(res.status).toBe(400);
});
it("returns 400 when apiKey field is missing", async () => {
const res = await app.request("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
});
it("returns 500 for invalid JSON body", async () => {
const res = await app.request("/api/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "not json",
});
expect(res.status).toBe(500);
});
});
describe("GET /api/settings", () => {
it("returns hasKey: true when key is configured", async () => {
mockGetKey.mockReturnValue("some-key");
const res = await app.request("/api/settings");
const data = await res.json();
expect(data.hasKey).toBe(true);
});
it("returns hasKey: false when no key configured", async () => {
mockGetKey.mockReturnValue(null);
const res = await app.request("/api/settings");
const data = await res.json();
expect(data.hasKey).toBe(false);
});
});
+29
View File
@@ -0,0 +1,29 @@
import { Hono } from "hono";
import { setRuntimeApiKey, getApiKey } from "../lib/tikhub";
const settingsRoutes = new Hono();
// POST / — save API Key
settingsRoutes.post("/", async (c) => {
try {
const body = await c.req.json();
const { apiKey } = body;
if (!apiKey || typeof apiKey !== "string" || apiKey.trim() === "") {
return c.json({ error: "请输入有效的 API Key" }, 400);
}
setRuntimeApiKey(apiKey.trim());
return c.json({ success: true, message: "API Key 已保存" });
} catch {
return c.json({ error: "保存失败,请重试" }, 500);
}
});
// GET / — check if API Key is configured
settingsRoutes.get("/", (c) => {
const hasKey = !!getApiKey();
return c.json({ hasKey });
});
export { settingsRoutes };
+157
View File
@@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("../lib/adapters", () => ({
getAdapter: vi.fn(),
}));
vi.mock("../lib/tikhub", () => {
class TikHubError extends Error {
constructor(
public statusCode: number,
message: string
) {
super(message);
this.name = "TikHubError";
}
}
return { TikHubError };
});
import { getAdapter } from "../lib/adapters";
import { TikHubError } from "../lib/tikhub";
import { app } from "../app";
const mockGetAdapter = vi.mocked(getAdapter);
describe("GET /api/tikhub/:platform", () => {
beforeEach(() => {
mockGetAdapter.mockReset();
});
it("returns content items for valid platform", async () => {
const mockItems = [{ id: "1", title: "Test", platform: "douyin" }];
mockGetAdapter.mockReturnValue({
fetchTrending: vi.fn().mockResolvedValue(mockItems),
fetchDetail: vi.fn(),
});
const res = await app.request("/api/tikhub/douyin");
const data = await res.json();
expect(res.status).toBe(200);
expect(data.data).toEqual(mockItems);
});
it("passes count parameter to adapter", async () => {
const mockFetchTrending = vi.fn().mockResolvedValue([]);
mockGetAdapter.mockReturnValue({
fetchTrending: mockFetchTrending,
fetchDetail: vi.fn(),
});
await app.request("/api/tikhub/tiktok?count=50");
expect(mockFetchTrending).toHaveBeenCalledWith(50);
});
it("defaults count to 20", async () => {
const mockFetchTrending = vi.fn().mockResolvedValue([]);
mockGetAdapter.mockReturnValue({
fetchTrending: mockFetchTrending,
fetchDetail: vi.fn(),
});
await app.request("/api/tikhub/douyin");
expect(mockFetchTrending).toHaveBeenCalledWith(20);
});
it("returns 400 for unsupported platform", async () => {
mockGetAdapter.mockReturnValue(null);
const res = await app.request("/api/tikhub/unknown");
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain("不支持的平台");
});
it("returns TikHub error status on TikHubError", async () => {
mockGetAdapter.mockReturnValue({
fetchTrending: vi.fn().mockRejectedValue(
new TikHubError(401, "API Key 无效")
),
fetchDetail: vi.fn(),
});
const res = await app.request("/api/tikhub/douyin");
expect(res.status).toBe(401);
const data = await res.json();
expect(data.error).toContain("API Key");
});
it("returns 500 for unexpected errors", async () => {
mockGetAdapter.mockReturnValue({
fetchTrending: vi.fn().mockRejectedValue(new Error("unexpected")),
fetchDetail: vi.fn(),
});
const res = await app.request("/api/tikhub/douyin");
expect(res.status).toBe(500);
const data = await res.json();
expect(data.error).toBe("服务器内部错误");
});
});
describe("GET /api/tikhub/:platform/detail", () => {
beforeEach(() => {
mockGetAdapter.mockReset();
});
it("returns detail for valid platform and id", async () => {
const mockItem = { id: "123", title: "Detail Item", platform: "douyin" };
mockGetAdapter.mockReturnValue({
fetchTrending: vi.fn(),
fetchDetail: vi.fn().mockResolvedValue(mockItem),
});
const res = await app.request("/api/tikhub/douyin/detail?id=123");
const data = await res.json();
expect(res.status).toBe(200);
expect(data.data).toEqual(mockItem);
});
it("returns 400 when id parameter is missing", async () => {
const res = await app.request("/api/tikhub/douyin/detail");
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain("id");
});
it("returns 400 for unsupported platform", async () => {
mockGetAdapter.mockReturnValue(null);
const res = await app.request("/api/tikhub/youtube/detail?id=123");
expect(res.status).toBe(400);
});
it("returns TikHub error status on TikHubError", async () => {
mockGetAdapter.mockReturnValue({
fetchTrending: vi.fn(),
fetchDetail: vi.fn().mockRejectedValue(
new TikHubError(429, "请求过于频繁")
),
});
const res = await app.request("/api/tikhub/douyin/detail?id=123");
expect(res.status).toBe(429);
});
it("returns 500 for unexpected errors", async () => {
mockGetAdapter.mockReturnValue({
fetchTrending: vi.fn(),
fetchDetail: vi.fn().mockRejectedValue(new Error("fail")),
});
const res = await app.request("/api/tikhub/douyin/detail?id=123");
expect(res.status).toBe(500);
});
});
+54
View File
@@ -0,0 +1,54 @@
import { Hono } from "hono";
import { getAdapter } from "../lib/adapters";
import { TikHubError } from "../lib/tikhub";
import type { Platform } from "@muse/shared";
const tikhubRoutes = new Hono();
// GET /:platform — trending content
tikhubRoutes.get("/:platform", async (c) => {
try {
const platform = c.req.param("platform");
const count = parseInt(c.req.query("count") || "20", 10);
const adapter = getAdapter(platform as Platform);
if (!adapter) {
return c.json({ error: `不支持的平台: ${platform}` }, 400);
}
const items = await adapter.fetchTrending(count);
return c.json({ data: items });
} catch (error) {
if (error instanceof TikHubError) {
return c.json({ error: error.message }, error.statusCode as 400);
}
return c.json({ error: "服务器内部错误" }, 500);
}
});
// GET /:platform/detail — content detail
tikhubRoutes.get("/:platform/detail", async (c) => {
try {
const platform = c.req.param("platform");
const id = c.req.query("id");
if (!id) {
return c.json({ error: "缺少参数: id" }, 400);
}
const adapter = getAdapter(platform as Platform);
if (!adapter) {
return c.json({ error: `不支持的平台: ${platform}` }, 400);
}
const item = await adapter.fetchDetail(id);
return c.json({ data: item });
} catch (error) {
if (error instanceof TikHubError) {
return c.json({ error: error.message }, error.statusCode as 400);
}
return c.json({ error: "服务器内部错误" }, 500);
}
});
export { tikhubRoutes };
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2017",
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"resolveJsonModule": true,
"paths": {
"@muse/shared": ["../shared/src/index.ts"]
}
},
"include": ["src/**/*.ts"]
}
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
resolve: {
alias: {
"@muse/shared": path.resolve(__dirname, "../shared/src/index.ts"),
},
},
test: {
globals: true,
include: ["src/**/*.test.ts"],
coverage: {
provider: "v8",
reporter: ["text", "text-summary", "lcov"],
include: ["src/**"],
exclude: ["src/**/*.test.*"],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
},
});
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+33
View File
@@ -0,0 +1,33 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: ["@muse/shared"],
images: {
remotePatterns: [
// 抖音
{ protocol: "https", hostname: "*.douyinpic.com" },
{ protocol: "https", hostname: "*.bytecdntp.com" },
{ protocol: "https", hostname: "*.byteimg.com" },
{ protocol: "https", hostname: "p*.douyinpic.com" },
// TikTok
{ protocol: "https", hostname: "*.tiktokcdn.com" },
{ protocol: "https", hostname: "*.tiktokcdn-us.com" },
{ protocol: "https", hostname: "p16-sign-sg.tiktokcdn.com" },
{ protocol: "https", hostname: "p16-common-sign.tiktokcdn-us.com" },
// 小红书
{ protocol: "https", hostname: "*.xhscdn.com" },
{ protocol: "https", hostname: "sns-webpic-qc.xhscdn.com" },
{ protocol: "https", hostname: "sns-avatar-qc.xhscdn.com" },
{ protocol: "https", hostname: "sns-na-i6.xhscdn.com" },
{ protocol: "https", hostname: "ci.xiaohongshu.com" },
{ protocol: "http", hostname: "ci.xiaohongshu.com" },
{ protocol: "https", hostname: "picasso-static.xiaohongshu.com" },
// 通用 CDN
{ protocol: "https", hostname: "*.pstatp.com" },
{ protocol: "https", hostname: "*.snssdk.com" },
{ protocol: "https", hostname: "*.douyinvod.com" },
],
},
};
export default nextConfig;
+48
View File
@@ -0,0 +1,48 @@
{
"name": "@muse/frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@muse/shared": "workspace:*",
"@tanstack/react-query": "^5.90.21",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.576.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"zustand": "^5.0.11"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"happy-dom": "^20.8.3",
"shadcn": "^3.8.5",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5",
"vitest": "^4.0.18"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
@@ -0,0 +1,67 @@
"use client";
import { use } from "react";
import { useDetailQuery } from "@/hooks/useDetailQuery";
import { DetailPanel } from "@/components/detail/DetailPanel";
import { DetailSkeleton } from "@/components/detail/DetailSkeleton";
import { ArrowLeft, RefreshCw } from "lucide-react";
import Link from "next/link";
interface DetailPageProps {
params: Promise<{ platform: string; id: string }>;
}
export default function DetailPage({ params }: DetailPageProps) {
const { platform, id } = use(params);
const decodedId = decodeURIComponent(id);
const { data, isLoading, isError, error, refetch } = useDetailQuery(
platform,
decodedId
);
if (isLoading) {
return (
<div className="px-4 py-6">
<DetailSkeleton />
</div>
);
}
if (isError || !data) {
return (
<div className="px-4 py-6">
<div className="max-w-3xl mx-auto text-center py-20">
<p className="text-4xl mb-4">😵</p>
<h2 className="text-lg font-medium text-slate-700 mb-2">
</h2>
<p className="text-sm text-slate-500 mb-6">
{error?.message || "无法获取内容详情"}
</p>
<div className="flex items-center justify-center gap-3">
<button
onClick={() => refetch()}
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
<RefreshCw className="w-4 h-4" />
</button>
<Link
href="/"
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
</Link>
</div>
</div>
</div>
);
}
return (
<div className="px-4 py-6">
<DetailPanel item={data} />
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,50 @@
"use client";
import { useFavoritesStore } from "@/stores/favorites";
import { ContentGrid } from "@/components/card/ContentGrid";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
export default function FavoritesPage() {
const favorites = useFavoritesStore((s) => s.items);
return (
<div className="px-4 py-4">
<div className="flex items-center gap-3 mb-4">
<Link
href="/"
className="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
</Link>
<h1 className="text-lg font-semibold text-slate-800">
</h1>
<span data-testid="favorites-count" className="text-sm text-slate-400">
{favorites.length}
</span>
</div>
{favorites.length === 0 ? (
<div className="text-center py-20">
<p className="text-4xl mb-4">💝</p>
<h2 className="text-lg font-medium text-slate-700 mb-2">
</h2>
<p className="text-sm text-slate-500 mb-4">
</p>
<Link
href="/"
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
</Link>
</div>
) : (
<ContentGrid items={favorites} />
)}
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+39
View File
@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "sonner";
import { QueryProvider } from "@/components/providers/QueryProvider";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Muse Creative Hotspots",
description: "全平台热点内容聚合浏览器",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-white`}
>
<QueryProvider>
{children}
<Toaster position="top-right" richColors closeButton />
</QueryProvider>
</body>
</html>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import { useContentQuery, useRefreshContent } from "@/hooks/useContentQuery";
import { PlatformTabs } from "@/components/layout/PlatformTabs";
import { SortToolbar, type SortField, type SortOrder } from "@/components/layout/SortToolbar";
import { ContentGrid } from "@/components/card/ContentGrid";
import type { ContentItem } from "@muse/shared";
function sortItems(
items: ContentItem[],
sortBy: SortField,
sortOrder: SortOrder
): ContentItem[] {
return [...items].sort((a, b) => {
let valA: number;
let valB: number;
if (sortBy === "publish_time") {
valA = new Date(a.publish_time).getTime() || 0;
valB = new Date(b.publish_time).getTime() || 0;
} else {
valA = (a[sortBy] as number) ?? 0;
valB = (b[sortBy] as number) ?? 0;
}
return sortOrder === "desc" ? valB - valA : valA - valB;
});
}
export default function Home() {
const [platform, setPlatform] = useState("all");
const [sortBy, setSortBy] = useState<SortField>("play_count");
const [sortOrder, setSortOrder] = useState<SortOrder>("desc");
const [lastRefreshTime, setLastRefreshTime] = useState<string | null>(null);
const { data, isLoading, isFetching, isError, error, refetch } = useContentQuery(platform);
const { refresh } = useRefreshContent();
const sortedItems = useMemo(() => {
if (!data) return [];
return sortItems(data, sortBy, sortOrder);
}, [data, sortBy, sortOrder]);
const handleRefresh = useCallback(() => {
refresh(platform);
setLastRefreshTime(
new Date().toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})
);
}, [refresh, platform]);
const handleSortOrderChange = useCallback(() => {
setSortOrder((prev) => (prev === "desc" ? "asc" : "desc"));
}, []);
return (
<div className="px-4 py-4">
<PlatformTabs active={platform} onChange={setPlatform} />
<SortToolbar
sortBy={sortBy}
sortOrder={sortOrder}
onSortByChange={setSortBy}
onSortOrderChange={handleSortOrderChange}
onRefresh={handleRefresh}
isRefreshing={isFetching}
lastRefreshTime={lastRefreshTime}
/>
{isError ? (
<div className="text-center py-20">
<p className="text-4xl mb-4">😵</p>
<h2 className="text-lg font-medium text-slate-700 mb-2">
</h2>
<p className="text-sm text-slate-500 mb-4">
{error?.message || "请求出错,请稍后重试"}
</p>
<button
data-testid="error-retry"
onClick={() => refetch()}
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
</button>
</div>
) : !isLoading && sortedItems.length === 0 ? (
<div data-testid="empty-state" className="text-center py-20">
<p className="text-4xl mb-4">📭</p>
<h2 className="text-lg font-medium text-slate-700 mb-2">
</h2>
<p className="text-sm text-slate-500">
API Key
</p>
</div>
) : (
<ContentGrid items={sortedItems} loading={isLoading} />
)}
</div>
);
}
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { useState } from "react";
import { ArrowLeft, Eye, EyeOff, Check } from "lucide-react";
import Link from "next/link";
import { useSettingsStore } from "@/stores/settings";
import { API_BASE_URL } from "@/lib/api";
import { toast } from "sonner";
const REFRESH_OPTIONS: { value: 5 | 10 | 15 | 30 | 60; label: string }[] = [
{ value: 5, label: "5 分钟" },
{ value: 10, label: "10 分钟" },
{ value: 15, label: "15 分钟" },
{ value: 30, label: "30 分钟" },
{ value: 60, label: "60 分钟" },
];
export default function SettingsPage() {
const { apiKey, setApiKey, refreshInterval, setRefreshInterval } =
useSettingsStore();
const [inputKey, setInputKey] = useState(apiKey);
const [showKey, setShowKey] = useState(false);
const [saving, setSaving] = useState(false);
const handleSaveApiKey = async () => {
const trimmed = inputKey.trim();
if (!trimmed) {
toast.error("请输入 API Key");
return;
}
setSaving(true);
try {
const res = await fetch(`${API_BASE_URL}/api/settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: trimmed }),
});
if (!res.ok) {
throw new Error("保存失败");
}
setApiKey(trimmed);
toast.success("API Key 已保存");
} catch {
toast.error("保存失败,请重试");
} finally {
setSaving(false);
}
};
return (
<div className="px-4 py-4 max-w-2xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<Link
href="/"
className="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
</Link>
<h1 className="text-lg font-semibold text-slate-800"></h1>
</div>
{/* API Key Section */}
<section className="mb-8">
<h2 className="text-sm font-medium text-slate-700 mb-1">
TikHub API Key
</h2>
<p className="text-xs text-slate-400 mb-3">
{" "}
<a
href="https://tikhub.io"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
tikhub.io
</a>{" "}
API Key
</p>
<div className="flex gap-2">
<div className="relative flex-1">
<input
data-testid="apikey-input"
type={showKey ? "text" : "password"}
value={inputKey}
onChange={(e) => setInputKey(e.target.value)}
placeholder="输入你的 API Key"
className="w-full text-sm border border-slate-200 rounded-lg px-3 py-2 pr-10 bg-white text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<button
type="button"
onClick={() => setShowKey(!showKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showKey ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
</div>
<button
data-testid="apikey-save"
onClick={handleSaveApiKey}
disabled={saving}
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{saving ? "保存中..." : "保存"}
</button>
</div>
</section>
{/* Refresh Interval Section */}
<section className="mb-8">
<h2 className="text-sm font-medium text-slate-700 mb-1">
</h2>
<p className="text-xs text-slate-400 mb-3">
</p>
<div className="flex flex-wrap gap-2">
{REFRESH_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setRefreshInterval(opt.value)}
className={`inline-flex items-center gap-1 px-3 py-1.5 text-sm rounded-lg border transition-colors ${
refreshInterval === opt.value
? "border-blue-500 bg-blue-50 text-blue-700"
: "border-slate-200 bg-white text-slate-600 hover:bg-slate-50"
}`}
>
{refreshInterval === opt.value && (
<Check className="w-3.5 h-3.5" />
)}
{opt.label}
</button>
))}
</div>
</section>
</div>
);
}
@@ -0,0 +1,27 @@
export function CardSkeleton() {
return (
<div className="rounded-lg border border-slate-200 bg-white overflow-hidden animate-pulse">
{/* Cover placeholder */}
<div className="aspect-[4/3] bg-slate-200" />
<div className="p-3 space-y-2">
{/* Platform tag */}
<div className="h-4 w-16 bg-slate-200 rounded" />
{/* Title */}
<div className="h-4 w-full bg-slate-200 rounded" />
<div className="h-4 w-2/3 bg-slate-200 rounded" />
{/* Author */}
<div className="flex items-center gap-1.5">
<div className="w-5 h-5 rounded-full bg-slate-200" />
<div className="h-3 w-20 bg-slate-200 rounded" />
</div>
{/* Stats */}
<div className="flex items-center gap-3">
<div className="h-3 w-10 bg-slate-200 rounded" />
<div className="h-3 w-10 bg-slate-200 rounded" />
<div className="h-3 w-10 bg-slate-200 rounded" />
</div>
</div>
</div>
);
}
@@ -0,0 +1,129 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { Play, Heart, Bookmark, Clock } from "lucide-react";
import { getPlatformConfig } from "@muse/shared";
import { formatCount, formatTime } from "@/lib/format";
import { FavoriteButton } from "@/components/common/FavoriteButton";
import type { ContentItem } from "@muse/shared";
import { useState } from "react";
interface ContentCardProps {
item: ContentItem;
}
export function ContentCard({ item }: ContentCardProps) {
const platform = getPlatformConfig(item.platform);
const [imgError, setImgError] = useState(false);
const playCount = formatCount(item.play_count);
const likeCount = formatCount(item.like_count);
const collectCount = formatCount(item.collect_count);
return (
<Link
href={`/detail/${item.platform}/${encodeURIComponent(item.id)}`}
data-testid="content-card"
className="group block rounded-lg border border-slate-200 bg-white shadow-sm overflow-hidden transition-all hover:-translate-y-0.5 hover:shadow-md"
>
{/* Cover image */}
<div className="relative aspect-[4/3] bg-slate-100 overflow-hidden">
{item.cover_url && !imgError ? (
<Image
src={item.cover_url}
alt={item.title}
fill
unoptimized
className="object-cover"
loading="lazy"
sizes="(max-width: 640px) 100vw, (max-width: 960px) 50vw, (max-width: 1240px) 33vw, 25vw"
onError={() => setImgError(true)}
/>
) : (
<div className="flex items-center justify-center h-full text-3xl text-slate-300">
{platform?.icon || "📄"}
</div>
)}
</div>
{/* Content */}
<div className="p-3">
{/* Platform tag + publish time */}
<div className="flex items-center justify-between mb-1.5">
{platform && (
<span
className="inline-block text-xs px-1.5 py-0.5 rounded"
style={{
backgroundColor: `${platform.color}15`,
color: platform.color,
}}
>
{platform.icon} {platform.name}
</span>
)}
{item.publish_time && (
<span className="flex items-center gap-0.5 text-[11px] text-slate-400">
<Clock className="w-3 h-3" />
{formatTime(item.publish_time)}
</span>
)}
</div>
{/* Title */}
<h3 className="text-sm font-medium text-slate-800 line-clamp-2 mb-2 leading-snug">
{item.title}
</h3>
{/* Author */}
<div className="flex items-center gap-1.5 mb-2">
{item.author_avatar ? (
<Image
src={item.author_avatar}
alt={item.author_name}
width={20}
height={20}
unoptimized
className="rounded-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
) : (
<div className="w-5 h-5 rounded-full bg-slate-200 flex items-center justify-center text-[10px] text-slate-500">
👤
</div>
)}
<span className="text-xs text-slate-500 truncate">
{item.author_name}
</span>
</div>
{/* Stats + Favorite */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-xs text-slate-400">
{playCount && (
<span className="flex items-center gap-0.5">
<Play className="w-3 h-3" />
{playCount}
</span>
)}
{likeCount && (
<span className="flex items-center gap-0.5">
<Heart className="w-3 h-3" />
{likeCount}
</span>
)}
{collectCount && (
<span className="flex items-center gap-0.5">
<Bookmark className="w-3 h-3" />
{collectCount}
</span>
)}
</div>
<FavoriteButton item={item} />
</div>
</div>
</Link>
);
}
@@ -0,0 +1,35 @@
"use client";
import { ContentCard } from "./ContentCard";
import { CardSkeleton } from "./CardSkeleton";
import type { ContentItem } from "@muse/shared";
interface ContentGridProps {
items: ContentItem[];
loading?: boolean;
skeletonCount?: number;
}
export function ContentGrid({
items,
loading,
skeletonCount = 12,
}: ContentGridProps) {
if (loading) {
return (
<div className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4">
{Array.from({ length: skeletonCount }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
);
}
return (
<div data-testid="content-grid" className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4">
{items.map((item) => (
<ContentCard key={`${item.platform}-${item.id}`} item={item} />
))}
</div>
);
}
@@ -0,0 +1,35 @@
import Link from "next/link";
interface EmptyStateProps {
icon?: string;
title: string;
description?: string;
actionLabel?: string;
actionHref?: string;
}
export function EmptyState({
icon = "📭",
title,
description,
actionLabel,
actionHref,
}: EmptyStateProps) {
return (
<div className="text-center py-20">
<p className="text-4xl mb-4">{icon}</p>
<h2 className="text-lg font-medium text-slate-700 mb-2">{title}</h2>
{description && (
<p className="text-sm text-slate-500 mb-4">{description}</p>
)}
{actionLabel && actionHref && (
<Link
href={actionHref}
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
{actionLabel}
</Link>
)}
</div>
);
}
@@ -0,0 +1,41 @@
import { RefreshCw } from "lucide-react";
import Link from "next/link";
interface ErrorStateProps {
message?: string;
onRetry?: () => void;
showHomeLink?: boolean;
}
export function ErrorState({
message = "加载失败,请稍后重试",
onRetry,
showHomeLink = true,
}: ErrorStateProps) {
return (
<div className="text-center py-20">
<p className="text-4xl mb-4">😵</p>
<h2 className="text-lg font-medium text-slate-700 mb-2"></h2>
<p className="text-sm text-slate-500 mb-6">{message}</p>
<div className="flex items-center justify-center gap-3">
{onRetry && (
<button
onClick={onRetry}
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
<RefreshCw className="w-4 h-4" />
</button>
)}
{showHomeLink && (
<Link
href="/"
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors"
>
</Link>
)}
</div>
</div>
);
}
@@ -0,0 +1,55 @@
"use client";
import { Heart } from "lucide-react";
import { useFavoritesStore } from "@/stores/favorites";
import type { ContentItem } from "@muse/shared";
import { cn } from "@/lib/utils";
import { useState } from "react";
interface FavoriteButtonProps {
item: ContentItem;
size?: "sm" | "md";
}
export function FavoriteButton({ item, size = "sm" }: FavoriteButtonProps) {
const { isFavorited, addFavorite, removeFavorite } = useFavoritesStore();
const favorited = isFavorited(item.id, item.platform);
const [animating, setAnimating] = useState(false);
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setAnimating(true);
setTimeout(() => setAnimating(false), 300);
if (favorited) {
removeFavorite(item.id, item.platform);
} else {
addFavorite(item);
}
};
const iconSize = size === "sm" ? "w-4 h-4" : "w-5 h-5";
return (
<button
data-testid="favorite-btn"
onClick={handleClick}
className={cn(
"inline-flex items-center justify-center rounded-md transition-colors",
size === "sm" ? "w-8 h-8" : "w-11 h-11",
favorited
? "text-red-500 hover:text-red-600"
: "text-slate-400 hover:text-red-400",
animating && "scale-110"
)}
style={{ transition: "transform 0.2s ease" }}
aria-label={favorited ? "取消收藏" : "收藏"}
>
<Heart
className={cn(iconSize, favorited && "fill-current")}
/>
</button>
);
}
@@ -0,0 +1,160 @@
"use client";
import Image from "next/image";
import { ArrowLeft, Play, Heart, Bookmark, MessageCircle, Share2, ExternalLink } from "lucide-react";
import { useRouter } from "next/navigation";
import { getPlatformConfig } from "@muse/shared";
import { formatCount, formatTime } from "@/lib/format";
import { FavoriteButton } from "@/components/common/FavoriteButton";
import type { ContentItem } from "@muse/shared";
import { useState } from "react";
interface DetailPanelProps {
item: ContentItem;
}
const STAT_ITEMS = [
{ key: "play_count" as const, label: "播放", icon: Play },
{ key: "like_count" as const, label: "点赞", icon: Heart },
{ key: "collect_count" as const, label: "收藏", icon: Bookmark },
{ key: "comment_count" as const, label: "评论", icon: MessageCircle },
{ key: "share_count" as const, label: "分享", icon: Share2 },
];
export function DetailPanel({ item }: DetailPanelProps) {
const router = useRouter();
const platform = getPlatformConfig(item.platform);
const [imgError, setImgError] = useState(false);
return (
<div className="max-w-3xl mx-auto">
{/* Back button */}
<button
data-testid="detail-back"
onClick={() => router.back()}
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-700 mb-4 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
</button>
{/* Cover image */}
<div className="relative aspect-video bg-slate-100 rounded-lg overflow-hidden">
{item.cover_url && !imgError ? (
<Image
src={item.cover_url}
alt={item.title}
fill
unoptimized
className="object-cover"
priority
sizes="(max-width: 768px) 100vw, 768px"
onError={() => setImgError(true)}
/>
) : (
<div className="flex items-center justify-center h-full text-5xl text-slate-300">
{platform?.icon || "📄"}
</div>
)}
</div>
{/* Content */}
<div className="mt-6 space-y-5">
{/* Platform tag */}
{platform && (
<span
className="inline-block text-xs px-2 py-0.5 rounded"
style={{
backgroundColor: `${platform.color}15`,
color: platform.color,
}}
>
{platform.icon} {platform.name}
</span>
)}
{/* Title */}
<h1 className="text-xl font-semibold text-slate-900 leading-relaxed">
{item.title}
</h1>
{/* Author */}
<div className="flex items-center gap-3">
{item.author_avatar ? (
<Image
src={item.author_avatar}
alt={item.author_name}
width={40}
height={40}
unoptimized
className="rounded-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
) : (
<div className="w-10 h-10 rounded-full bg-slate-200 flex items-center justify-center text-lg text-slate-500">
👤
</div>
)}
<div>
<p className="text-sm font-medium text-slate-700">
{item.author_name}
</p>
{item.publish_time && (
<p className="text-xs text-slate-400">
{formatTime(item.publish_time)}
</p>
)}
</div>
</div>
{/* Stats panel */}
<div className="grid grid-cols-5 gap-3">
{STAT_ITEMS.map(({ key, label, icon: Icon }) => {
const value = item[key];
return (
<div
key={key}
className="flex flex-col items-center gap-1 py-3 bg-slate-50 rounded-lg"
>
<Icon className="w-4 h-4 text-slate-400" />
<span className="text-lg font-semibold text-slate-800">
{formatCount(value) || "0"}
</span>
<span className="text-xs text-slate-400">{label}</span>
</div>
);
})}
</div>
{/* Tags */}
{item.tags && item.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{item.tags.map((tag) => (
<span
key={tag}
className="text-xs px-2.5 py-1 bg-slate-100 text-slate-600 rounded-full"
>
#{tag}
</span>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-3 pt-2">
<button
data-testid="view-original"
onClick={() => window.open(item.original_url, "_blank")}
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
<ExternalLink className="w-4 h-4" />
</button>
<FavoriteButton item={item} size="md" />
</div>
</div>
</div>
);
}
@@ -0,0 +1,34 @@
export function DetailSkeleton() {
return (
<div className="max-w-3xl mx-auto animate-pulse">
{/* Cover */}
<div className="aspect-video bg-slate-200 rounded-lg" />
<div className="mt-6 space-y-4">
{/* Title */}
<div className="h-7 w-3/4 bg-slate-200 rounded" />
<div className="h-7 w-1/2 bg-slate-200 rounded" />
{/* Author */}
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-slate-200" />
<div className="h-4 w-24 bg-slate-200 rounded" />
</div>
{/* Stats */}
<div className="flex gap-6">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-16 w-24 bg-slate-200 rounded-lg" />
))}
</div>
{/* Tags */}
<div className="flex gap-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-6 w-16 bg-slate-200 rounded-full" />
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,55 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Heart, Settings } from "lucide-react";
import { PlatformTabs } from "./PlatformTabs";
interface HeaderProps {
activePlatform?: string;
onPlatformChange?: (platform: string) => void;
}
export function Header({ activePlatform, onPlatformChange }: HeaderProps) {
const pathname = usePathname();
const isHome = pathname === "/";
return (
<header className="sticky top-0 z-50 w-full bg-white border-b border-slate-200">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex h-14 items-center justify-between">
{/* Logo */}
<Link href="/" className="text-xl font-bold text-slate-800 shrink-0">
Muse
</Link>
{/* Platform Tabs - only on home page */}
{isHome && onPlatformChange && (
<div className="flex-1 flex justify-center mx-4 overflow-x-auto">
<PlatformTabs
active={activePlatform || "all"}
onChange={onPlatformChange}
/>
</div>
)}
{/* Right actions */}
<div className="flex items-center gap-2 shrink-0">
<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>
</div>
</header>
);
}
@@ -0,0 +1,42 @@
"use client";
import { MVP_PLATFORMS } from "@muse/shared";
import { cn } from "@/lib/utils";
interface PlatformTabsProps {
active: string;
onChange: (platform: string) => void;
}
const ALL_TAB = { id: "all", name: "全部", icon: "🌐", color: "#2563EB" };
export function PlatformTabs({ active, onChange }: PlatformTabsProps) {
const tabs = [ALL_TAB, ...MVP_PLATFORMS.map((p) => ({ id: p.id, name: p.name, icon: p.icon, color: p.color }))];
return (
<div className="flex items-center gap-1">
{tabs.map((tab) => (
<button
key={tab.id}
data-testid={`platform-tab-${tab.id}`}
onClick={() => onChange(tab.id)}
className={cn(
"relative px-3 py-1.5 text-sm font-medium rounded-md transition-colors whitespace-nowrap",
active === tab.id
? "text-slate-800"
: "text-slate-500 hover:text-slate-700 hover:bg-slate-50"
)}
>
<span className="mr-1">{tab.icon}</span>
{tab.name}
{active === tab.id && (
<span
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-6 h-0.5 rounded-full"
style={{ backgroundColor: tab.color }}
/>
)}
</button>
))}
</div>
);
}
@@ -0,0 +1,81 @@
"use client";
import { RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils";
export type SortField = "play_count" | "like_count" | "collect_count" | "comment_count" | "publish_time";
export type SortOrder = "asc" | "desc";
const SORT_OPTIONS: { value: SortField; label: string }[] = [
{ value: "play_count", label: "播放量" },
{ value: "like_count", label: "点赞数" },
{ value: "collect_count", label: "收藏量" },
{ value: "comment_count", label: "评论数" },
{ value: "publish_time", label: "发布时间" },
];
interface SortToolbarProps {
sortBy: SortField;
sortOrder: SortOrder;
onSortByChange: (field: SortField) => void;
onSortOrderChange: () => void;
onRefresh: () => void;
isRefreshing: boolean;
lastRefreshTime: string | null;
}
export function SortToolbar({
sortBy,
sortOrder,
onSortByChange,
onSortOrderChange,
onRefresh,
isRefreshing,
lastRefreshTime,
}: SortToolbarProps) {
return (
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<span className="text-sm text-slate-500">:</span>
<select
data-testid="sort-select"
value={sortBy}
onChange={(e) => onSortByChange(e.target.value as SortField)}
className="text-sm border border-slate-200 rounded-md px-2 py-1 bg-white text-slate-700 focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button
data-testid="sort-order"
onClick={onSortOrderChange}
className="text-sm border border-slate-200 rounded-md px-2 py-1 bg-white text-slate-700 hover:bg-slate-50 transition-colors"
>
{sortOrder === "desc" ? "↓ 降序" : "↑ 升序"}
</button>
</div>
<div className="flex items-center gap-2">
<button
data-testid="refresh-btn"
onClick={onRefresh}
disabled={isRefreshing}
className="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700 disabled:opacity-50 transition-colors"
>
<RefreshCw
className={cn("w-4 h-4", isRefreshing && "animate-spin")}
/>
</button>
{lastRefreshTime && (
<span className="text-xs text-slate-400">
: {lastRefreshTime}
</span>
)}
</div>
</div>
);
}
@@ -0,0 +1,22 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
@@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
@@ -0,0 +1,40 @@
"use client"
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }
@@ -0,0 +1,60 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useSettingsStore } from "@/stores/settings";
import type { ContentItem } from "@muse/shared";
import { MVP_PLATFORMS } from "@muse/shared";
import { API_BASE_URL } from "@/lib/api";
async function fetchPlatformContent(platform: string): Promise<ContentItem[]> {
const res = await fetch(`${API_BASE_URL}/api/tikhub/${platform}?count=20`);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `请求失败: ${res.status}`);
}
const data = await res.json();
return data.data || [];
}
async function fetchAllPlatforms(): Promise<ContentItem[]> {
const results = await Promise.allSettled(
MVP_PLATFORMS.filter((p) => p.enabled).map((p) => fetchPlatformContent(p.id))
);
const fulfilled = results.filter(
(r): r is PromiseFulfilledResult<ContentItem[]> => r.status === "fulfilled"
);
// If ALL requests failed, throw the first error so the UI can show it
if (fulfilled.length === 0 && results.length > 0) {
const firstError = results.find(
(r): r is PromiseRejectedResult => r.status === "rejected"
);
throw firstError?.reason || new Error("所有平台请求失败");
}
return fulfilled.flatMap((r) => r.value);
}
export function useContentQuery(platform: string) {
const refreshInterval = useSettingsStore((s) => s.refreshInterval);
return useQuery<ContentItem[]>({
queryKey: ["content", platform],
queryFn: () =>
platform === "all"
? fetchAllPlatforms()
: fetchPlatformContent(platform),
refetchInterval: refreshInterval * 60 * 1000,
refetchIntervalInBackground: false,
});
}
export function useRefreshContent() {
const queryClient = useQueryClient();
return {
refresh: (platform: string) =>
queryClient.invalidateQueries({ queryKey: ["content", platform] }),
};
}
@@ -0,0 +1,57 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { ContentItem } from "@muse/shared";
import { API_BASE_URL } from "@/lib/api";
async function fetchDetail(
platform: string,
id: string
): Promise<ContentItem> {
const res = await fetch(
`${API_BASE_URL}/api/tikhub/${platform}/detail?id=${encodeURIComponent(id)}`
);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `请求失败: ${res.status}`);
}
const data = await res.json();
return data.data;
}
/**
* Look up an item from TanStack Query's trending list cache.
* Checks both platform-specific and "all" caches.
*/
function findCachedItem(
queryClient: ReturnType<typeof useQueryClient>,
platform: string,
id: string
): ContentItem | undefined {
const keys = [["content", platform], ["content", "all"]];
for (const key of keys) {
const items = queryClient.getQueryData<ContentItem[]>(key);
if (items) {
const found = items.find(
(item) => item.id === id && item.platform === platform
);
if (found) return found;
}
}
return undefined;
}
export function useDetailQuery(platform: string, id: string) {
const queryClient = useQueryClient();
const cached = findCachedItem(queryClient, platform, id);
return useQuery<ContentItem>({
queryKey: ["detail", platform, id],
queryFn: () => fetchDetail(platform, id),
// Skip API call if item already in trending cache
enabled: !!platform && !!id && !cached,
initialData: cached,
staleTime: 5 * 60 * 1000,
retry: 1,
});
}
+2
View File
@@ -0,0 +1,2 @@
export const API_BASE_URL =
process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
+73
View File
@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { formatCount, formatTime } from "./format";
describe("formatCount", () => {
it("returns null for undefined", () => {
expect(formatCount(undefined)).toBeNull();
});
it("returns null for null (cast)", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect(formatCount(null as any)).toBeNull();
});
it("returns raw number for values < 1000", () => {
expect(formatCount(0)).toBe("0");
expect(formatCount(1)).toBe("1");
expect(formatCount(999)).toBe("999");
});
it("formats thousands with K suffix", () => {
expect(formatCount(1000)).toBe("1.0K");
expect(formatCount(1500)).toBe("1.5K");
expect(formatCount(5300)).toBe("5.3K");
expect(formatCount(999_999)).toBe("1000.0K");
});
it("formats millions with M suffix", () => {
expect(formatCount(1_000_000)).toBe("1.0M");
expect(formatCount(1_200_000)).toBe("1.2M");
expect(formatCount(53_000_000)).toBe("53.0M");
});
});
describe("formatTime", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-03T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it('returns "刚刚" for times less than 1 minute ago', () => {
expect(formatTime("2026-03-03T12:00:00Z")).toBe("刚刚");
expect(formatTime("2026-03-03T11:59:30Z")).toBe("刚刚");
});
it("returns minutes ago for times < 60 minutes", () => {
expect(formatTime("2026-03-03T11:55:00Z")).toBe("5分钟前");
expect(formatTime("2026-03-03T11:01:00Z")).toBe("59分钟前");
});
it("returns hours ago for times < 24 hours", () => {
expect(formatTime("2026-03-03T10:00:00Z")).toBe("2小时前");
expect(formatTime("2026-03-02T13:00:00Z")).toBe("23小时前");
});
it("returns days ago for times < 30 days", () => {
expect(formatTime("2026-03-02T12:00:00Z")).toBe("1天前");
expect(formatTime("2026-02-10T12:00:00Z")).toBe("21天前");
});
it("returns formatted date for times >= 30 days ago", () => {
const result = formatTime("2026-01-01T12:00:00Z");
expect(result).toBeTruthy();
expect(result).not.toBe("");
});
it("returns 'Invalid Date' for invalid date string", () => {
expect(formatTime("invalid-date")).toBe("Invalid Date");
});
});
+29
View File
@@ -0,0 +1,29 @@
export function formatCount(count: number | undefined): string | null {
if (count === undefined || count === null) return null;
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
return String(count);
}
export function formatTime(isoString: string): string {
try {
const date = new Date(isoString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffHour = Math.floor(diffMs / 3_600_000);
const diffDay = Math.floor(diffMs / 86_400_000);
if (diffMin < 1) return "刚刚";
if (diffMin < 60) return `${diffMin}分钟前`;
if (diffHour < 24) return `${diffHour}小时前`;
if (diffDay < 30) return `${diffDay}天前`;
return date.toLocaleDateString("zh-CN", {
month: "short",
day: "numeric",
});
} catch {
return "";
}
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
@@ -0,0 +1,120 @@
import { describe, it, expect, beforeEach } from "vitest";
import { useFavoritesStore } from "./favorites";
import type { ContentItem } from "@muse/shared";
const mockItem: ContentItem = {
id: "123",
title: "Test Item",
author_name: "Test Author",
publish_time: "2026-03-03T12:00:00Z",
platform: "douyin",
original_url: "https://douyin.com/video/123",
};
const mockItem2: ContentItem = {
id: "456",
title: "Second Item",
author_name: "Author 2",
publish_time: "2026-03-03T12:00:00Z",
platform: "tiktok",
original_url: "https://tiktok.com/@user/video/456",
};
const mockItemSameIdDiffPlatform: ContentItem = {
...mockItem,
platform: "tiktok",
original_url: "https://tiktok.com/@user/video/123",
};
describe("useFavoritesStore", () => {
beforeEach(() => {
// Reset store state before each test
useFavoritesStore.setState({ items: [] });
});
describe("addFavorite", () => {
it("adds an item to favorites", () => {
useFavoritesStore.getState().addFavorite(mockItem);
expect(useFavoritesStore.getState().items).toHaveLength(1);
expect(useFavoritesStore.getState().items[0]).toEqual(mockItem);
});
it("does not add duplicate item (same id + platform)", () => {
useFavoritesStore.getState().addFavorite(mockItem);
useFavoritesStore.getState().addFavorite(mockItem);
expect(useFavoritesStore.getState().items).toHaveLength(1);
});
it("adds item with same id but different platform", () => {
useFavoritesStore.getState().addFavorite(mockItem);
useFavoritesStore.getState().addFavorite(mockItemSameIdDiffPlatform);
expect(useFavoritesStore.getState().items).toHaveLength(2);
});
it("adds multiple different items", () => {
useFavoritesStore.getState().addFavorite(mockItem);
useFavoritesStore.getState().addFavorite(mockItem2);
expect(useFavoritesStore.getState().items).toHaveLength(2);
});
});
describe("removeFavorite", () => {
it("removes item by id and platform", () => {
useFavoritesStore.getState().addFavorite(mockItem);
useFavoritesStore.getState().addFavorite(mockItem2);
useFavoritesStore.getState().removeFavorite("123", "douyin");
const items = useFavoritesStore.getState().items;
expect(items).toHaveLength(1);
expect(items[0].id).toBe("456");
});
it("does nothing when removing non-existent item", () => {
useFavoritesStore.getState().addFavorite(mockItem);
useFavoritesStore.getState().removeFavorite("999", "douyin");
expect(useFavoritesStore.getState().items).toHaveLength(1);
});
it("only removes matching platform (not same id different platform)", () => {
useFavoritesStore.getState().addFavorite(mockItem);
useFavoritesStore.getState().addFavorite(mockItemSameIdDiffPlatform);
useFavoritesStore.getState().removeFavorite("123", "douyin");
const items = useFavoritesStore.getState().items;
expect(items).toHaveLength(1);
expect(items[0].platform).toBe("tiktok");
});
});
describe("isFavorited", () => {
it("returns false when item is not favorited", () => {
expect(
useFavoritesStore.getState().isFavorited("123", "douyin")
).toBe(false);
});
it("returns true when item is favorited", () => {
useFavoritesStore.getState().addFavorite(mockItem);
expect(
useFavoritesStore.getState().isFavorited("123", "douyin")
).toBe(true);
});
it("returns false for same id but different platform", () => {
useFavoritesStore.getState().addFavorite(mockItem);
expect(
useFavoritesStore.getState().isFavorited("123", "tiktok")
).toBe(false);
});
it("returns false after item is removed", () => {
useFavoritesStore.getState().addFavorite(mockItem);
useFavoritesStore.getState().removeFavorite("123", "douyin");
expect(
useFavoritesStore.getState().isFavorited("123", "douyin")
).toBe(false);
});
});
});
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { ContentItem, Platform } from "@muse/shared";
interface FavoritesStore {
items: ContentItem[];
addFavorite: (item: ContentItem) => void;
removeFavorite: (id: string, platform: Platform) => void;
isFavorited: (id: string, platform: Platform) => boolean;
}
export const useFavoritesStore = create<FavoritesStore>()(
persist(
(set, get) => ({
items: [],
addFavorite: (item) =>
set((state) => {
// Dedup by id + platform
const exists = state.items.some(
(i) => i.id === item.id && i.platform === item.platform
);
if (exists) return state;
return { items: [...state.items, item] };
}),
removeFavorite: (id, platform) =>
set((state) => ({
items: state.items.filter(
(i) => !(i.id === id && i.platform === platform)
),
})),
isFavorited: (id, platform) =>
get().items.some((i) => i.id === id && i.platform === platform),
}),
{
name: "muse-favorites",
// Handle data corruption
onRehydrateStorage: () => (state) => {
if (state && !Array.isArray(state.items)) {
state.items = [];
}
},
}
)
);
@@ -0,0 +1,89 @@
import { describe, it, expect, beforeEach } from "vitest";
import { useSettingsStore } from "./settings";
describe("useSettingsStore", () => {
beforeEach(() => {
// Reset store to default state
useSettingsStore.setState({
apiKey: "",
refreshInterval: 30,
enabledPlatforms: {
douyin: true,
tiktok: true,
xiaohongshu: true,
},
displayCount: 20,
});
});
describe("default values", () => {
it("has empty apiKey by default", () => {
expect(useSettingsStore.getState().apiKey).toBe("");
});
it("has 30 minute refresh interval by default", () => {
expect(useSettingsStore.getState().refreshInterval).toBe(30);
});
it("has all 3 platforms enabled by default", () => {
const platforms = useSettingsStore.getState().enabledPlatforms;
expect(platforms.douyin).toBe(true);
expect(platforms.tiktok).toBe(true);
expect(platforms.xiaohongshu).toBe(true);
});
it("has display count of 20 by default", () => {
expect(useSettingsStore.getState().displayCount).toBe(20);
});
});
describe("setApiKey", () => {
it("sets the API key", () => {
useSettingsStore.getState().setApiKey("test-key-123");
expect(useSettingsStore.getState().apiKey).toBe("test-key-123");
});
it("can clear the API key", () => {
useSettingsStore.getState().setApiKey("test-key");
useSettingsStore.getState().setApiKey("");
expect(useSettingsStore.getState().apiKey).toBe("");
});
});
describe("setRefreshInterval", () => {
it("sets refresh interval to valid values", () => {
const validValues: (5 | 10 | 15 | 30 | 60)[] = [5, 10, 15, 30, 60];
validValues.forEach((val) => {
useSettingsStore.getState().setRefreshInterval(val);
expect(useSettingsStore.getState().refreshInterval).toBe(val);
});
});
});
describe("togglePlatform", () => {
it("disables an enabled platform", () => {
useSettingsStore.getState().togglePlatform("douyin");
expect(useSettingsStore.getState().enabledPlatforms.douyin).toBe(false);
});
it("enables a disabled platform", () => {
useSettingsStore.getState().togglePlatform("douyin");
useSettingsStore.getState().togglePlatform("douyin");
expect(useSettingsStore.getState().enabledPlatforms.douyin).toBe(true);
});
it("does not affect other platforms", () => {
useSettingsStore.getState().togglePlatform("tiktok");
expect(useSettingsStore.getState().enabledPlatforms.douyin).toBe(true);
expect(useSettingsStore.getState().enabledPlatforms.tiktok).toBe(false);
expect(useSettingsStore.getState().enabledPlatforms.xiaohongshu).toBe(true);
});
});
describe("setDisplayCount", () => {
it("sets display count", () => {
useSettingsStore.getState().setDisplayCount(50);
expect(useSettingsStore.getState().displayCount).toBe(50);
});
});
});
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { Platform } from "@muse/shared";
interface SettingsStore {
apiKey: string;
refreshInterval: 5 | 10 | 15 | 30 | 60;
enabledPlatforms: Record<string, boolean>;
displayCount: number;
setApiKey: (key: string) => void;
setRefreshInterval: (minutes: 5 | 10 | 15 | 30 | 60) => void;
togglePlatform: (platform: Platform) => void;
setDisplayCount: (count: number) => void;
}
export const useSettingsStore = create<SettingsStore>()(
persist(
(set) => ({
apiKey: "",
refreshInterval: 30,
enabledPlatforms: {
douyin: true,
tiktok: true,
xiaohongshu: true,
},
displayCount: 20,
setApiKey: (key) => set({ apiKey: key }),
setRefreshInterval: (minutes) => set({ refreshInterval: minutes }),
togglePlatform: (platform) =>
set((state) => ({
enabledPlatforms: {
...state.enabledPlatforms,
[platform]: !state.enabledPlatforms[platform],
},
})),
setDisplayCount: (count) => set({ displayCount: count }),
}),
{
name: "muse-settings",
}
)
);
+35
View File
@@ -0,0 +1,35 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"],
"@muse/shared": ["../shared/src/index.ts"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"@muse/shared": path.resolve(__dirname, "../shared/src/index.ts"),
},
},
test: {
environment: "happy-dom",
globals: true,
setupFiles: ["./vitest.setup.ts"],
include: ["src/**/*.test.{ts,tsx}"],
coverage: {
provider: "v8",
reporter: ["text", "text-summary", "lcov"],
include: ["src/lib/**", "src/stores/**"],
exclude: ["src/**/*.test.*", "src/**/ui/**"],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
},
});
+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@muse/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"devDependencies": {
"typescript": "^5",
"vitest": "^4.0.18",
"@vitest/coverage-v8": "^4.0.18"
}
}
+8
View File
@@ -0,0 +1,8 @@
export type {
Platform,
ContentItem,
PlatformConfig,
PlatformAdapter,
} from "./types/content";
export { MVP_PLATFORMS, getPlatformConfig } from "./platforms";
+60
View File
@@ -0,0 +1,60 @@
import { describe, it, expect } from "vitest";
import { MVP_PLATFORMS, getPlatformConfig } from "./platforms";
describe("MVP_PLATFORMS", () => {
it("contains exactly 8 platforms", () => {
expect(MVP_PLATFORMS).toHaveLength(8);
});
it("includes all 8 platforms", () => {
const ids = MVP_PLATFORMS.map((p) => p.id);
expect(ids).toContain("douyin");
expect(ids).toContain("tiktok");
expect(ids).toContain("xiaohongshu");
expect(ids).toContain("youtube");
expect(ids).toContain("instagram");
expect(ids).toContain("twitter");
expect(ids).toContain("bilibili");
expect(ids).toContain("weibo");
});
it("all platforms are enabled by default", () => {
MVP_PLATFORMS.forEach((p) => {
expect(p.enabled).toBe(true);
});
});
it("each platform has required fields", () => {
MVP_PLATFORMS.forEach((p) => {
expect(p.id).toBeTruthy();
expect(p.name).toBeTruthy();
expect(p.icon).toBeTruthy();
expect(p.color).toBeTruthy();
expect(p.endpoints.trending).toBeTruthy();
expect(p.endpoints.detail).toBeTruthy();
});
});
});
describe("getPlatformConfig", () => {
it("returns config for known platform", () => {
const config = getPlatformConfig("douyin");
expect(config).toBeDefined();
expect(config!.id).toBe("douyin");
expect(config!.name).toBe("抖音");
});
it("returns undefined for unknown platform", () => {
expect(getPlatformConfig("unknown")).toBeUndefined();
});
it("returns correct config for each platform", () => {
expect(getPlatformConfig("tiktok")?.name).toBe("TikTok");
expect(getPlatformConfig("xiaohongshu")?.name).toBe("小红书");
expect(getPlatformConfig("youtube")?.name).toBe("YouTube");
expect(getPlatformConfig("instagram")?.name).toBe("Instagram");
expect(getPlatformConfig("twitter")?.name).toBe("Twitter/X");
expect(getPlatformConfig("bilibili")?.name).toBe("哔哩哔哩");
expect(getPlatformConfig("weibo")?.name).toBe("微博");
});
});
+96
View File
@@ -0,0 +1,96 @@
import type { PlatformConfig } from "./types/content";
export const MVP_PLATFORMS: PlatformConfig[] = [
{
id: "douyin",
name: "抖音",
icon: "📱",
color: "#000000",
enabled: true,
endpoints: {
trending: "/api/v1/douyin/web/fetch_hot_search_result",
detail: "/api/v1/douyin/web/fetch_one_video",
},
},
{
id: "tiktok",
name: "TikTok",
icon: "🎵",
color: "#00F2EA",
enabled: true,
endpoints: {
trending: "/api/v1/tiktok/web/fetch_trending_post",
detail: "/api/v1/tiktok/web/fetch_post_detail",
},
},
{
id: "xiaohongshu",
name: "小红书",
icon: "📕",
color: "#FF2442",
enabled: true,
endpoints: {
trending: "/api/v1/xiaohongshu/app/v2/fetch_feed",
detail: "/api/v1/xiaohongshu/app/v2/fetch_note_detail",
},
},
{
id: "youtube",
name: "YouTube",
icon: "▶️",
color: "#FF0000",
enabled: true,
endpoints: {
trending: "/api/v1/youtube/web/get_trending_videos",
detail: "/api/v1/youtube/web/get_video_info",
},
},
{
id: "instagram",
name: "Instagram",
icon: "📷",
color: "#E4405F",
enabled: true,
endpoints: {
trending: "/api/v1/instagram/v1/fetch_explore_sections",
detail: "/api/v1/instagram/v2/fetch_post_info",
},
},
{
id: "twitter",
name: "Twitter/X",
icon: "𝕏",
color: "#000000",
enabled: true,
endpoints: {
trending: "/api/v1/twitter/web/fetch_trending",
detail: "/api/v1/twitter/web/fetch_tweet_detail",
},
},
{
id: "bilibili",
name: "哔哩哔哩",
icon: "📺",
color: "#00A1D6",
enabled: true,
endpoints: {
trending: "/api/v1/bilibili/web/fetch_com_popular",
detail: "/api/v1/bilibili/web/fetch_video_detail",
},
},
{
id: "weibo",
name: "微博",
icon: "🔥",
color: "#FF8200",
enabled: true,
endpoints: {
trending: "/api/v1/weibo/app/fetch_hot_search",
detail: "/api/v1/weibo/app/fetch_status_detail",
},
},
];
export function getPlatformConfig(platformId: string): PlatformConfig | undefined {
return MVP_PLATFORMS.find((p) => p.id === platformId);
}
+44
View File
@@ -0,0 +1,44 @@
export type Platform =
| "douyin"
| "tiktok"
| "xiaohongshu"
| "youtube"
| "instagram"
| "twitter"
| "bilibili"
| "weibo";
export interface ContentItem {
id: string;
title: string;
cover_url?: string;
video_url?: string;
author_name: string;
author_avatar?: string;
play_count?: number;
like_count?: number;
collect_count?: number;
comment_count?: number;
share_count?: number;
publish_time: string;
platform: Platform;
original_url: string;
tags?: string[];
}
export interface PlatformConfig {
id: Platform;
name: string;
icon: string;
color: string;
enabled: boolean;
endpoints: {
trending: string;
detail: string;
};
}
export interface PlatformAdapter {
fetchTrending(count: number): Promise<ContentItem[]>;
fetchDetail(id: string): Promise<ContentItem>;
}
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2017",
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
include: ["src/**/*.test.ts"],
coverage: {
provider: "v8",
reporter: ["text", "text-summary", "lcov"],
include: ["src/**"],
exclude: ["src/**/*.test.*"],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
},
});