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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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("未知作者");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type {
|
||||
Platform,
|
||||
ContentItem,
|
||||
PlatformConfig,
|
||||
PlatformAdapter,
|
||||
} from "./types/content";
|
||||
|
||||
export { MVP_PLATFORMS, getPlatformConfig } from "./platforms";
|
||||
@@ -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("微博");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user