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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user