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