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

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

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

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